diff --git a/.github/test-env-configs.json b/.github/test-env-configs.json
index da75e7b..88d25f8 100644
--- a/.github/test-env-configs.json
+++ b/.github/test-env-configs.json
@@ -11,7 +11,8 @@
"AWS_CDN_HOST": "https://cdn.example.com",
"DISABLE_POKECODE_FOOTER": "",
"DISABLE_LANGUAGE_MENU": "",
- "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": ""
+ "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "",
+ "ALLOWED_RECIPIENTS": ""
},
{
"env-config": "all-disabled",
@@ -25,7 +26,8 @@
"AWS_CDN_HOST": "",
"DISABLE_POKECODE_FOOTER": "1",
"DISABLE_LANGUAGE_MENU": "1",
- "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1"
+ "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1",
+ "ALLOWED_RECIPIENTS": "recipient1@example.com recipient2@example.com"
},
{
"env-config": "footer-only",
@@ -39,7 +41,8 @@
"AWS_CDN_HOST": "",
"DISABLE_POKECODE_FOOTER": "",
"DISABLE_LANGUAGE_MENU": "1",
- "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1"
+ "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1",
+ "ALLOWED_RECIPIENTS": ""
},
{
"env-config": "language-menu-only",
@@ -53,7 +56,8 @@
"AWS_CDN_HOST": "",
"DISABLE_POKECODE_FOOTER": "1",
"DISABLE_LANGUAGE_MENU": "",
- "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1"
+ "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1",
+ "ALLOWED_RECIPIENTS": ""
},
{
"env-config": "assembly-members-visible-only",
@@ -67,7 +71,8 @@
"AWS_CDN_HOST": "",
"DISABLE_POKECODE_FOOTER": "1",
"DISABLE_LANGUAGE_MENU": "1",
- "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": ""
+ "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "",
+ "ALLOWED_RECIPIENTS": "recipient1@example.com recipient2@example.com"
},
{
"env-config": "admin-iframe-only",
@@ -81,6 +86,7 @@
"AWS_CDN_HOST": "",
"DISABLE_POKECODE_FOOTER": "1",
"DISABLE_LANGUAGE_MENU": "1",
- "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1"
+ "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1",
+ "ALLOWED_RECIPIENTS": "recipient1@example.com recipient2@example.com"
}
]
diff --git a/README.md b/README.md
index a186f1d..df4c2b2 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ This plugin relies on the command `decidim:upgrade` to make sure common files ar
| `AWS_PUBLIC` | Usually, to be used in combination with the previous option. This generates assets without signatures, which basically means they don't expire. | `true` | |
| `AWS_FORCE_PATH_STYLE` | Certain providers do not support the bucket name as the subdomain of the AWS endpoint (ie: Contabo). Set to `true` if that's the case. | `false` | |
| `CONTENT_SECURITY_POLICY` | Sets custom Content Security Policy headers for enhanced security. When set, it is added to the default CSP configuration. | `""` (disabled) | |
+| `ALLOWED_RECIPIENTS` | A list of emails or domains that must match in order to send an email, separated by spaces. For instance `@pokecode.net johnsmith@gmail.com`. Exact email addresses (without a leading `@`) must match the full recipient email, while domain patterns starting with `@` are matched as suffixes of the recipient email. Leave empty to disable any interception. | `""` |
## Installation
diff --git a/app/overrides/decidim/add_staging_warning.rb b/app/overrides/decidim/add_staging_warning.rb
new file mode 100644
index 0000000..07a241e
--- /dev/null
+++ b/app/overrides/decidim/add_staging_warning.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+if Decidim::Pokecode.allowed_recipients_list.any?
+ Deface::Override.new(:virtual_path => "decidim/admin/dashboard/show",
+ :name => "add-staging-warning",
+ :insert_before => "div.content",
+ :partial => "decidim/pokecode/staging_warning")
+end
diff --git a/app/views/decidim/pokecode/_staging_warning.html.erb b/app/views/decidim/pokecode/_staging_warning.html.erb
new file mode 100644
index 0000000..69a9017
--- /dev/null
+++ b/app/views/decidim/pokecode/_staging_warning.html.erb
@@ -0,0 +1,6 @@
+
+
+ <%= t("decidim.pokecode.staging_warning.title") %>
+ <%= t("decidim.pokecode.staging_warning.message", emails: Decidim::Pokecode.allowed_recipients_list.map {|email| "#{email}"}.join(", ")).html_safe %>
+
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 232ff21..f893e0e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1,6 +1,7 @@
---
en:
decidim:
- components:
- Pokecode:
- name: Pokecode
+ pokecode:
+ staging_warning:
+ title: "Warning: Staging Environment"
+ message: "Emails sent from this instance may not reach their intended recipients. Allowed destinations are: %{emails}."
diff --git a/lib/decidim/pokecode.rb b/lib/decidim/pokecode.rb
index 2cf4bda..4e96e76 100644
--- a/lib/decidim/pokecode.rb
+++ b/lib/decidim/pokecode.rb
@@ -19,6 +19,8 @@
require "sidekiq/cron"
end
+require "decidim/pokecode/mail_interceptor" if Decidim::Pokecode.allowed_recipients_list.any?
+
require "decidim/pokecode/admin"
require "decidim/pokecode/engine"
require "decidim/pokecode/admin_engine"
diff --git a/lib/decidim/pokecode/configuration.rb b/lib/decidim/pokecode/configuration.rb
index 7e7e4c4..a120730 100644
--- a/lib/decidim/pokecode/configuration.rb
+++ b/lib/decidim/pokecode/configuration.rb
@@ -65,6 +65,10 @@ module Pokecode
host.present? && host.starts_with?("https://") ? host : ""
end
+ config_accessor :allowed_recipients do
+ Decidim::Env.new("ALLOWED_RECIPIENTS", "").value
+ end
+
config_accessor :content_security_policies_extra do
{
"connect-src" => ENV.fetch("CONTENT_SECURITY_POLICY", "").split,
@@ -86,6 +90,10 @@ def self.rack_attack_ips
Pokecode.rack_attack_allowed_ips&.split(/[,\s]+/)&.reject(&:blank?) || []
end
+ def self.allowed_recipients_list
+ Pokecode.allowed_recipients&.split(/[,\s]+/)&.reject(&:blank?) || []
+ end
+
def self.deface_enabled
Pokecode.pokecode_footer_enabled || Decidim::Pokecode.language_menu_enabled
end
diff --git a/lib/decidim/pokecode/engine.rb b/lib/decidim/pokecode/engine.rb
index ed4cc65..04c1915 100644
--- a/lib/decidim/pokecode/engine.rb
+++ b/lib/decidim/pokecode/engine.rb
@@ -168,6 +168,15 @@ class Engine < ::Rails::Engine
initializer "pokecode.shakapacker.assets_path" do
Decidim.register_assets_path File.expand_path("app/packs", root)
end
+
+ initializer "pokecode.mail_interceptor" do
+ if Decidim::Pokecode.allowed_recipients_list.any?
+ Mail.register_interceptor(Decidim::Pokecode::MailInterceptor)
+ Rails.logger.info "[Decidim::Pokecode] Email interceptor enabled. Allowed recipients: #{Decidim::Pokecode.allowed_recipients_list.join(", ")}"
+ else
+ Rails.logger.info "[Decidim::Pokecode] Email interceptor disabled."
+ end
+ end
end
end
end
diff --git a/lib/decidim/pokecode/mail_interceptor.rb b/lib/decidim/pokecode/mail_interceptor.rb
new file mode 100644
index 0000000..0538a06
--- /dev/null
+++ b/lib/decidim/pokecode/mail_interceptor.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Decidim
+ module Pokecode
+ # Email interceptor that filters outgoing emails based on allowed recipients.
+ # Only allows emails to recipients that match the configured allowed list.
+ class MailInterceptor
+ def self.delivering_email(message)
+ return if allowed_recipients_empty?
+
+ # Get all recipients from the email message
+ to_recipients = Array(message.to).map(&:downcase)
+ cc_recipients = Array(message.cc).map(&:downcase)
+ bcc_recipients = Array(message.bcc).map(&:downcase)
+
+ all_recipients = to_recipients + cc_recipients + bcc_recipients
+
+ # Filter recipients to only those in the allowed list
+ filtered_recipients = all_recipients.select { |recipient| allowed?(recipient) }
+
+ if filtered_recipients.empty?
+ Rails.logger.warn "[Decidim::Pokecode] Email delivery intercepted. No recipients matched allowed list. Original recipients: #{all_recipients.join(", ")}"
+ message.perform_deliveries = false
+ else
+ # Update the message with only the allowed recipients
+ message.to = (to_recipients & filtered_recipients).uniq
+ message.cc = (cc_recipients & filtered_recipients).uniq
+ message.bcc = (bcc_recipients & filtered_recipients).uniq
+
+ Rails.logger.info "[Decidim::Pokecode] Email delivery filtered. Original recipients: #{all_recipients.join(", ")}, Allowed recipients: #{filtered_recipients.join(", ")}"
+ end
+ end
+
+ def self.allowed_recipients_empty?
+ Decidim::Pokecode.allowed_recipients_list.empty?
+ end
+
+ def self.allowed?(email)
+ email_lowercase = email.downcase
+ Decidim::Pokecode.allowed_recipients_list.any? do |allowed|
+ allowed_lowercase = allowed.downcase
+ # Check if the email matches or ends with the allowed value
+ # This allows both exact email matches and domain-based matches (e.g., @pokecode.net)
+ email_lowercase == allowed_lowercase || email_lowercase.end_with?(allowed_lowercase.to_s.start_with?("@") ? allowed_lowercase : "@#{allowed_lowercase}")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/overrides_spec.rb b/spec/lib/overrides_spec.rb
index ffd67e0..ac7422f 100644
--- a/spec/lib/overrides_spec.rb
+++ b/spec/lib/overrides_spec.rb
@@ -20,6 +20,12 @@
"/app/permissions/decidim/assemblies/permissions.rb" => "f26397a30c34eeb60af141b8ef0eb1bb"
}
},
+ {
+ package: "decidim-admin",
+ files: {
+ "/app/views/decidim/admin/dashboard/show.html.erb" => "45558619f30212c2aa079e744c4be4ea"
+ }
+ },
{
package: "aws-sdk-s3",
files: {
diff --git a/spec/mailers/mail_interceptor_spec.rb b/spec/mailers/mail_interceptor_spec.rb
new file mode 100644
index 0000000..972c512
--- /dev/null
+++ b/spec/mailers/mail_interceptor_spec.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+require "decidim/pokecode/mail_interceptor"
+
+module Decidim::Pokecode
+ describe MailInterceptor do
+ let(:message) do
+ instance_double(
+ Mail::Message,
+ to: to_recipients,
+ cc: cc_recipients,
+ bcc: bcc_recipients,
+ perform_deliveries: true
+ )
+ end
+
+ let(:to_recipients) { [] }
+ let(:cc_recipients) { [] }
+ let(:bcc_recipients) { [] }
+ let(:allowed_recipients) { [] }
+
+ before do
+ allow(Rails.logger).to receive(:warn)
+ allow(Rails.logger).to receive(:info)
+ allow(message).to receive(:to=)
+ allow(message).to receive(:cc=)
+ allow(message).to receive(:bcc=)
+ allow(message).to receive(:perform_deliveries=)
+ end
+
+ describe ".delivering_email" do
+ before do
+ allow(Decidim::Pokecode).to receive(:allowed_recipients_list).and_return(allowed_recipients)
+ end
+
+ context "when allowed_recipients is empty" do
+ it "does not intercept the email" do
+ described_class.delivering_email(message)
+ expect(message).not_to have_received(:perform_deliveries=)
+ end
+ end
+
+ context "when allowed_recipients is configured" do
+ context "with domain-based allowed recipients" do
+ let(:allowed_recipients) { ["@pokecode.net"] }
+
+ context "when recipients match the allowed domain" do
+ let(:to_recipients) { ["user@pokecode.net", "admin@pokecode.net"] }
+
+ it "allows the email" do
+ described_class.delivering_email(message)
+ expect(message).not_to have_received(:perform_deliveries=)
+ expect(message).to have_received(:to=).with(["user@pokecode.net", "admin@pokecode.net"].uniq)
+ end
+
+ it "logs the filtering" do
+ described_class.delivering_email(message)
+ expect(Rails.logger).to have_received(:info)
+ end
+ end
+
+ context "when recipients don't match the allowed domain" do
+ let(:to_recipients) { ["user@example.com", "admin@gmail.com"] }
+
+ it "blocks the email" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:perform_deliveries=).with(false)
+ end
+
+ it "logs the interception" do
+ described_class.delivering_email(message)
+ expect(Rails.logger).to have_received(:warn)
+ end
+ end
+
+ context "with mixed recipients" do
+ let(:to_recipients) { ["user@pokecode.net", "admin@example.com"] }
+ let(:cc_recipients) { ["cc@pokecode.net"] }
+ let(:bcc_recipients) { ["bcc@gmail.com"] }
+
+ it "filters to only allowed recipients" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:to=).with(["user@pokecode.net"])
+ expect(message).to have_received(:cc=).with(["cc@pokecode.net"])
+ expect(message).to have_received(:bcc=).with([])
+ end
+ end
+ end
+
+ context "with exact email allowed recipients" do
+ let(:allowed_recipients) { ["john@example.com", "jane@example.com"] }
+
+ context "when recipients match exactly" do
+ let(:to_recipients) { ["john@example.com"] }
+
+ it "allows the email" do
+ described_class.delivering_email(message)
+ expect(message).not_to have_received(:perform_deliveries=)
+ expect(message).to have_received(:to=).with(["john@example.com"])
+ end
+ end
+
+ context "when recipients don't match" do
+ let(:to_recipients) { ["bob@example.com"] }
+
+ it "blocks the email" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:perform_deliveries=).with(false)
+ end
+ end
+ end
+
+ context "with mixed allowed recipients" do
+ let(:allowed_recipients) { ["@pokecode.net", "external@gmail.com"] }
+
+ context "when recipients match domain or exact email" do
+ let(:to_recipients) { ["user@pokecode.net", "external@gmail.com"] }
+
+ it "allows the email" do
+ described_class.delivering_email(message)
+ expect(message).not_to have_received(:perform_deliveries=)
+ expect(message).to have_received(:to=).with(["user@pokecode.net", "external@gmail.com"])
+ end
+ end
+
+ context "when some recipients don't match" do
+ let(:to_recipients) { ["user@pokecode.net", "other@gmail.com"] }
+
+ it "filters to only allowed recipients" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:to=).with(["user@pokecode.net"])
+ end
+ end
+ end
+
+ context "with case-insensitive matching" do
+ let(:allowed_recipients) { ["@pokecode.net", "John@Example.Com"] }
+
+ context "with uppercase recipients" do
+ let(:to_recipients) { ["USER@POKECODE.NET", "JOHN@EXAMPLE.COM"] }
+
+ it "matches case-insensitively" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:to=).with(["user@pokecode.net", "john@example.com"])
+ end
+ end
+
+ context "with mixed case recipients" do
+ let(:to_recipients) { ["user@PokeCode.Net", "John@example.com"] }
+
+ it "matches case-insensitively" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:to=).with(["user@pokecode.net", "john@example.com"])
+ end
+ end
+ end
+
+ context "when only BCC recipients match" do
+ let(:allowed_recipients) { ["@pokecode.net"] }
+ let(:bcc_recipients) { ["admin@pokecode.net"] }
+
+ it "allows the email with only BCC" do
+ described_class.delivering_email(message)
+ expect(message).to have_received(:bcc=).with(["admin@pokecode.net"])
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/system/allowed_recipients_spec.rb b/spec/system/allowed_recipients_spec.rb
new file mode 100644
index 0000000..74256d4
--- /dev/null
+++ b/spec/system/allowed_recipients_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe "AllowedRecipients" do
+ let!(:organization) { create(:organization) }
+ let!(:user) { create(:user, :confirmed, :admin, organization:) }
+
+ before do
+ switch_to_host(organization.host)
+ login_as user, scope: :user
+ visit decidim_admin.root_path
+ end
+
+ if Decidim::Pokecode.allowed_recipients_list.any?
+ it "shows the staging warning in the admin dashboard" do
+ expect(page).to have_content("Warning: Staging Environment")
+ Decidim::Pokecode.allowed_recipients_list.each do |email|
+ expect(page).to have_content(email)
+ end
+ end
+ else
+ it "does not show the staging warning in the admin dashboard" do
+ expect(page).to have_no_content("Warning: Staging Environment")
+ end
+ end
+end