Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/mailers/decidim/pokecode/rate_limit_mail_interceptor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module Decidim
module Pokecode
# Email interceptor that enforces daily and/or monthly sending quotas.
# When a quota is exceeded the email is not delivered, a warning is logged,
# and an ActiveSupport::Notifications event is published so that subscribers
# can react (e.g. alert an operator).
#
# Quotas are stored in Redis and controlled via two ENV variables:
# ACTION_MAILER_MAX_EMAILS_PER_DAY – maximum emails allowed per calendar day
# ACTION_MAILER_MAX_EMAILS_PER_MONTH – maximum emails allowed per calendar month
class RateLimitMailInterceptor
# Event name published when an email is skipped due to quota being exceeded.
QUOTA_EXCEEDED_EVENT = "decidim.pokecode.mail_quota_exceeded"

def self.delivering_email(message)
return unless Decidim::Pokecode.rate_limit_enabled?

quota = MailQuota.new

if daily_limit_exceeded?(quota)
skip_delivery!(message, :daily, quota.daily_count, Decidim::Pokecode.max_emails_per_day)
elsif monthly_limit_exceeded?(quota)
skip_delivery!(message, :monthly, quota.monthly_count, Decidim::Pokecode.max_emails_per_month)
else
quota.increment!
end
end

def self.daily_limit_exceeded?(quota)
limit = Decidim::Pokecode.max_emails_per_day
limit.present? && quota.daily_count >= limit
end

def self.monthly_limit_exceeded?(quota)
limit = Decidim::Pokecode.max_emails_per_month
limit.present? && quota.monthly_count >= limit
end

def self.skip_delivery!(message, period, current_count, limit)
message.perform_deliveries = false

Rails.logger.warn(
"[Decidim::Pokecode] Email delivery skipped: #{period} quota exceeded " \
"(#{current_count}/#{limit}). To: #{Array(message.to).join(", ")}"
)

ActiveSupport::Notifications.instrument(
QUOTA_EXCEEDED_EVENT,
period: period,
current_count: current_count,
limit: limit,
recipients: Array(message.to)
)
end
end
end
end
71 changes: 71 additions & 0 deletions app/models/decidim/pokecode/mail_quota.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

module Decidim
module Pokecode
# Provides read/write access to the email sending quota stored in Redis.
# Counters are stored with keys scoped by day and month and expire
# automatically when the period rolls over.
#
# Usage:
# quota = Decidim::Pokecode::MailQuota.new
# quota.daily_count # => Integer
# quota.monthly_count # => Integer
# quota.increment! # increments both counters
class MailQuota
DAILY_KEY_PREFIX = "decidim:pokecode:mail_quota:daily"
MONTHLY_KEY_PREFIX = "decidim:pokecode:mail_quota:monthly"

# Returns the current number of emails sent today.
def daily_count
redis.get(daily_key).to_i
end

# Returns the current number of emails sent this month.
def monthly_count
redis.get(monthly_key).to_i
end

# Increments both the daily and monthly counters and sets their TTL
# (so they expire automatically at the end of each period).
def increment!
now = Time.current

redis.multi do |pipeline|
pipeline.incr(daily_key)
pipeline.expireat(daily_key, end_of_day(now).to_i)

pipeline.incr(monthly_key)
pipeline.expireat(monthly_key, end_of_month(now).to_i)
end
end

# Returns the Redis key for the current day's counter.
def daily_key
"#{DAILY_KEY_PREFIX}:#{Time.current.strftime("%Y-%m-%d")}"
end

# Returns the Redis key for the current month's counter.
def monthly_key
"#{MONTHLY_KEY_PREFIX}:#{Time.current.strftime("%Y-%m")}"
end

private

def redis
self.class.redis
end

def end_of_day(time)
time.end_of_day
end

def end_of_month(time)
time.end_of_month
end

def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
end
end
14 changes: 14 additions & 0 deletions lib/decidim/pokecode/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ module Pokecode
Decidim::Env.new("DISABLE_INVITATIONS", false).present?
end

config_accessor :max_emails_per_day do
val = Decidim::Env.new("ACTION_MAILER_MAX_EMAILS_PER_DAY", nil).value
val.present? ? val.to_i : nil
end

config_accessor :max_emails_per_month do
val = Decidim::Env.new("ACTION_MAILER_MAX_EMAILS_PER_MONTH", nil).value
val.present? ? val.to_i : nil
end

config_accessor :content_security_policies_extra do
{
"connect-src" => ENV.fetch("CONTENT_SECURITY_POLICY", "").split,
Expand Down Expand Up @@ -124,5 +134,9 @@ def self.active_storage_s3_urls
urls << ActiveStorage::Blob.service.bucket.url if defined?(ActiveStorage::Service::S3Service) && ActiveStorage::Blob.service.is_a?(ActiveStorage::Service::S3Service)
urls
end

def self.rate_limit_enabled?
Pokecode.max_emails_per_day.present? || Pokecode.max_emails_per_month.present?
end
end
end
11 changes: 11 additions & 0 deletions lib/decidim/pokecode/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ class Engine < ::Rails::Engine
else
Rails.logger.info "[Decidim::Pokecode] Invitations not disabled via mail interceptor."
end

if Decidim::Pokecode.rate_limit_enabled?
unless ActionMailer::Base.try(:delivery_interceptors)&.include?(Decidim::Pokecode::RateLimitMailInterceptor)
ActionMailer::Base.register_interceptor(Decidim::Pokecode::RateLimitMailInterceptor)
end
Rails.logger.info "[Decidim::Pokecode] Rate limit mail interceptor enabled. " \
"Max per day: #{Decidim::Pokecode.max_emails_per_day || "unlimited"}, " \
"Max per month: #{Decidim::Pokecode.max_emails_per_month || "unlimited"}."
else
Rails.logger.info "[Decidim::Pokecode] Rate limit mail interceptor disabled."
end
end
end

Expand Down
213 changes: 213 additions & 0 deletions spec/mailers/rate_limit_mail_interceptor_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# frozen_string_literal: true

require "spec_helper"

module Decidim::Pokecode
describe RateLimitMailInterceptor do
let(:message) do
instance_double(
Mail::Message,
to: ["user@example.com"],
perform_deliveries: true
)
end

let(:quota) { instance_double(MailQuota, daily_count: 0, monthly_count: 0) }

before do
allow(Rails.logger).to receive(:warn)
allow(Rails.logger).to receive(:info)
allow(message).to receive(:perform_deliveries=)
allow(MailQuota).to receive(:new).and_return(quota)
allow(quota).to receive(:increment!)
end

describe ".delivering_email" do
context "when rate limiting is disabled (no limits configured)" do
before do
allow(Decidim::Pokecode).to receive(:rate_limit_enabled?).and_return(false)
end

it "does not check quota" do
described_class.delivering_email(message)
expect(MailQuota).not_to have_received(:new)
end

it "does not block delivery" do
described_class.delivering_email(message)
expect(message).not_to have_received(:perform_deliveries=)
end
end

context "when rate limiting is enabled" do
before do
allow(Decidim::Pokecode).to receive(:rate_limit_enabled?).and_return(true)
allow(Decidim::Pokecode).to receive(:max_emails_per_day).and_return(max_per_day)
allow(Decidim::Pokecode).to receive(:max_emails_per_month).and_return(max_per_month)
end

let(:max_per_day) { nil }
let(:max_per_month) { nil }

context "when daily limit is set and not exceeded" do
let(:max_per_day) { 100 }

before do
allow(quota).to receive(:daily_count).and_return(50)
end

it "allows delivery and increments quota" do
described_class.delivering_email(message)
expect(message).not_to have_received(:perform_deliveries=)
expect(quota).to have_received(:increment!)
end
end

context "when daily limit is set and exactly reached" do
let(:max_per_day) { 100 }

before do
allow(quota).to receive(:daily_count).and_return(100)
end

it "blocks delivery" do
described_class.delivering_email(message)
expect(message).to have_received(:perform_deliveries=).with(false)
end

it "logs a warning" do
described_class.delivering_email(message)
expect(Rails.logger).to have_received(:warn).with(include("daily quota exceeded"))
end

it "publishes a quota exceeded event" do
events = []
ActiveSupport::Notifications.subscribed(
->(name, _start, _finish, _id, payload) { events << { name: name, payload: payload } },
RateLimitMailInterceptor::QUOTA_EXCEEDED_EVENT
) do
described_class.delivering_email(message)
end

expect(events.length).to eq(1)
expect(events.first[:payload][:period]).to eq(:daily)
expect(events.first[:payload][:current_count]).to eq(100)
expect(events.first[:payload][:limit]).to eq(100)
end

it "does not increment quota" do
described_class.delivering_email(message)
expect(quota).not_to have_received(:increment!)
end
end

context "when daily limit is exceeded" do
let(:max_per_day) { 50 }

before do
allow(quota).to receive(:daily_count).and_return(75)
end

it "blocks delivery" do
described_class.delivering_email(message)
expect(message).to have_received(:perform_deliveries=).with(false)
end

it "logs a warning with count and limit" do
described_class.delivering_email(message)
expect(Rails.logger).to have_received(:warn).with(include("75/50"))
end
end

context "when monthly limit is set and not exceeded" do
let(:max_per_month) { 1000 }

before do
allow(quota).to receive(:monthly_count).and_return(500)
end

it "allows delivery and increments quota" do
described_class.delivering_email(message)
expect(message).not_to have_received(:perform_deliveries=)
expect(quota).to have_received(:increment!)
end
end

context "when monthly limit is reached" do
let(:max_per_month) { 1000 }

before do
allow(quota).to receive(:monthly_count).and_return(1000)
end

it "blocks delivery" do
described_class.delivering_email(message)
expect(message).to have_received(:perform_deliveries=).with(false)
end

it "logs a warning" do
described_class.delivering_email(message)
expect(Rails.logger).to have_received(:warn).with(include("monthly quota exceeded"))
end

it "publishes a monthly quota exceeded event" do
events = []
ActiveSupport::Notifications.subscribed(
->(name, _start, _finish, _id, payload) { events << { name: name, payload: payload } },
RateLimitMailInterceptor::QUOTA_EXCEEDED_EVENT
) do
described_class.delivering_email(message)
end

expect(events.length).to eq(1)
expect(events.first[:payload][:period]).to eq(:monthly)
end
end

context "when both daily and monthly limits are set" do
let(:max_per_day) { 10 }
let(:max_per_month) { 100 }

context "when daily limit is exceeded" do
before do
allow(quota).to receive(:daily_count).and_return(10)
allow(quota).to receive(:monthly_count).and_return(50)
end

it "blocks on daily limit and does not check monthly" do
described_class.delivering_email(message)
expect(message).to have_received(:perform_deliveries=).with(false)
expect(Rails.logger).to have_received(:warn).with(include("daily quota exceeded"))
end
end

context "when only monthly limit is exceeded" do
before do
allow(quota).to receive(:daily_count).and_return(5)
allow(quota).to receive(:monthly_count).and_return(100)
end

it "blocks on monthly limit" do
described_class.delivering_email(message)
expect(message).to have_received(:perform_deliveries=).with(false)
expect(Rails.logger).to have_received(:warn).with(include("monthly quota exceeded"))
end
end

context "when neither limit is exceeded" do
before do
allow(quota).to receive(:daily_count).and_return(5)
allow(quota).to receive(:monthly_count).and_return(50)
end

it "allows delivery and increments quota" do
described_class.delivering_email(message)
expect(message).not_to have_received(:perform_deliveries=)
expect(quota).to have_received(:increment!)
end
end
end
end
end
end
end
Loading