From c884cdefde7736b4de0f92da3594bbb9b8f4759d Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 3 Feb 2026 02:06:51 +0530 Subject: [PATCH] feat: add per-account daily rate limit for outbound emails (#13411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a daily cap on non-channel outbound emails to prevent abuse. Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28 ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality not to work as expected) ## Summary - Adds a Redis-based daily counter to rate limit outbound emails per account, preventing email abuse - Covers continuity emails (WebWidget/API), conversation transcripts, and agent notifications - Email channel replies are excluded (paid feature, not abusable) - Adds account suspension check in `ConversationReplyMailer` to block already-queued emails for suspended accounts ## Limit Resolution Hierarchy 1. Per-account override (`account.limits['emails']`) — SuperAdmin configurable 2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS` InstallationConfig) 3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default: 100) 4. Fallback (`ChatwootApp.max_limit` — effectively unlimited) ## Enforcement Points | Path | Where | Behavior | |------|-------|----------| | WebWidget/API continuity | `SendEmailNotificationService#should_send_email_notification?` | Silently skipped | | Widget transcript | `Widget::ConversationsController#transcript` | Returns 429 | | API transcript | `ConversationsController#transcript` | Returns 429 | | Agent notifications | `Notification::EmailNotificationService#perform` | Silently skipped | | Email channel replies | Not rate limited | Paid feature | | Suspended accounts | `ConversationReplyMailer` | Blocked at mailer level | --- .../v1/accounts/conversations_controller.rb | 2 + .../api/v1/widget/conversations_controller.rb | 19 ++++-- .../super_admin/app_configs_controller.rb | 2 +- app/jobs/conversation_reply_email_job.rb | 1 + app/mailers/conversation_reply_mailer.rb | 1 + app/models/account.rb | 1 + .../concerns/account_email_rate_limitable.rb | 49 +++++++++++++++ .../send_email_notification_service.rb | 2 + .../email_notification_service.rb | 15 +++-- config/installation_config.yml | 10 +++ enterprise/app/fields/account_limits_field.rb | 2 +- .../account/plan_usage_and_limits.rb | 19 +++++- lib/redis/redis_keys.rb | 3 + .../account_email_rate_limitable_spec.rb | 63 +++++++++++++++++++ .../send_email_notification_service_spec.rb | 14 +++++ 15 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 app/models/concerns/account_email_rate_limitable.rb create mode 100644 spec/models/concerns/account_email_rate_limitable_spec.rb diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e2b930ac9..b3151c8fa 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def transcript render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? + return head :too_many_requests unless @conversation.account.within_email_rate_limit? ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later + @conversation.account.increment_email_sent_count head :ok end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index fe5facc1a..96c15fde2 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - if conversation.present? && conversation.contact.present? && conversation.contact.email.present? - ConversationReplyMailer.with(account: conversation.account).conversation_transcript( - conversation, - conversation.contact.email - )&.deliver_later - end + return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit? + + send_transcript_email head :ok end @@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController private + def send_transcript_email + return if conversation.contact&.email.blank? + + ConversationReplyMailer.with(account: conversation.account).conversation_transcript( + conversation, + conversation.contact.email + )&.deliver_later + conversation.account.increment_email_sent_count + end + def trigger_typing_event(event) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b910a9c9a..67d58aef1 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], - 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], + 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], diff --git a/app/jobs/conversation_reply_email_job.rb b/app/jobs/conversation_reply_email_job.rb index 5d186bf29..9d4c120c8 100644 --- a/app/jobs/conversation_reply_email_job.rb +++ b/app/jobs/conversation_reply_email_job.rb @@ -3,6 +3,7 @@ class ConversationReplyEmailJob < ApplicationJob def perform(conversation_id, last_queued_id) conversation = Conversation.find(conversation_id) + return unless conversation.account.active? if conversation.messages.incoming&.last&.content_type == 'incoming_email' ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 8dbe67bf8..7fee05596 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer return unless smtp_config_set_or_development? init_conversation_attributes(message.conversation) + @message = message prepare_mail(true) end diff --git a/app/models/account.rb b/app/models/account.rb index df79ee6c1..fead5f0f7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -29,6 +29,7 @@ class Account < ApplicationRecord include Featurable include CacheKeys include CaptainFeaturable + include AccountEmailRateLimitable SETTINGS_PARAMS_SCHEMA = { 'type': 'object', diff --git a/app/models/concerns/account_email_rate_limitable.rb b/app/models/concerns/account_email_rate_limitable.rb new file mode 100644 index 000000000..e967408fc --- /dev/null +++ b/app/models/concerns/account_email_rate_limitable.rb @@ -0,0 +1,49 @@ +module AccountEmailRateLimitable + extend ActiveSupport::Concern + + OUTBOUND_EMAIL_TTL = 25.hours.to_i + EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze + + def email_rate_limit + account_limit || global_limit || default_limit + end + + def emails_sent_today + Redis::Alfred.get(email_count_cache_key).to_i + end + + def within_email_rate_limit? + return true if emails_sent_today < email_rate_limit + + Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}") + false + end + + def increment_email_sent_count + Redis::Alfred.incr(email_count_cache_key).tap do |count| + Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1 + end + end + + private + + def email_count_cache_key + @email_count_cache_key ||= format( + Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, + account_id: id, + date: Time.zone.today.to_s + ) + end + + def account_limit + self[:limits]&.dig('emails')&.to_i + end + + def global_limit + GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i + end + + def default_limit + ChatwootApp.max_limit.to_i + end +end diff --git a/app/services/messages/send_email_notification_service.rb b/app/services/messages/send_email_notification_service.rb index 25a77b0d5..dd4f5006e 100644 --- a/app/services/messages/send_email_notification_service.rb +++ b/app/services/messages/send_email_notification_service.rb @@ -13,6 +13,7 @@ class Messages::SendEmailNotificationService return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i) ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id) + message.account.increment_email_sent_count end private @@ -20,6 +21,7 @@ class Messages::SendEmailNotificationService def should_send_email_notification? return false unless message.email_notifiable_message? return false if message.conversation.contact.email.blank? + return false unless message.account.within_email_rate_limit? email_reply_enabled? end diff --git a/app/services/notification/email_notification_service.rb b/app/services/notification/email_notification_service.rb index fbec8b86f..6fc68560b 100644 --- a/app/services/notification/email_notification_service.rb +++ b/app/services/notification/email_notification_service.rb @@ -7,15 +7,22 @@ class Notification::EmailNotificationService # don't send emails if user is not confirmed return if notification.user.confirmed_at.nil? return unless user_subscribed_to_notification? + return unless notification.account.within_email_rate_limit? - # TODO : Clean up whatever happening over here - # Segregate the mailers properly - AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification - .notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later + send_notification_email + notification.account.increment_email_sent_count end private + # TODO : Clean up whatever happening over here + # Segregate the mailers properly + def send_notification_email + AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send( + notification.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor + ).deliver_later + end + def user_subscribed_to_notification? notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id) return true if notification_setting.public_send("email_#{notification.notification_type}?") diff --git a/config/installation_config.yml b/config/installation_config.yml index 946b81e8e..34cb736bf 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -107,6 +107,16 @@ value: description: 'The support email address for your installation' locked: false +- name: ACCOUNT_EMAILS_LIMIT + display_title: 'Account Email Sending Limit (Daily)' + description: 'Maximum number of non-channel emails an account can send per day' + value: 100 + locked: false +- name: ACCOUNT_EMAILS_PLAN_LIMITS + display_title: 'Account Email Plan Limits (Daily)' + description: 'Per-plan daily email sending limits as JSON' + value: + type: code # ------- End of Email Related Config ------- # # ------- Facebook Channel Related Config ------- # diff --git a/enterprise/app/fields/account_limits_field.rb b/enterprise/app/fields/account_limits_field.rb index b6aecd79f..2a46426b7 100644 --- a/enterprise/app/fields/account_limits_field.rb +++ b/enterprise/app/fields/account_limits_field.rb @@ -2,6 +2,6 @@ require 'administrate/field/base' class AccountLimitsField < Administrate::Field::Base def to_s - data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json + data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json end end diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb index ce03efa41..ee0803469 100644 --- a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -1,4 +1,4 @@ -module Enterprise::Account::PlanUsageAndLimits +module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength CAPTAIN_RESPONSES = 'captain_responses'.freeze CAPTAIN_DOCUMENTS = 'captain_documents'.freeze CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze @@ -32,6 +32,10 @@ module Enterprise::Account::PlanUsageAndLimits save end + def email_rate_limit + account_limit || plan_email_limit || global_limit || default_limit + end + def subscribed_features plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value return [] if plan_features.blank? @@ -68,6 +72,16 @@ module Enterprise::Account::PlanUsageAndLimits } end + def plan_email_limit + config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value + return nil if config.blank? || plan_name.blank? + + parsed = config.is_a?(String) ? JSON.parse(config) : config + parsed[plan_name.downcase]&.to_i + rescue StandardError + nil + end + def default_captain_limits max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access zero_limits = { documents: 0, responses: 0 }.with_indifferent_access @@ -119,7 +133,8 @@ module Enterprise::Account::PlanUsageAndLimits 'inboxes' => { 'type': 'number' }, 'agents' => { 'type': 'number' }, 'captain_responses' => { 'type': 'number' }, - 'captain_documents' => { 'type': 'number' } + 'captain_documents' => { 'type': 'number' }, + 'emails' => { 'type': 'number' } }, 'required' => [], 'additionalProperties' => false diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 973c2b188..8c9361ab5 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -49,4 +49,7 @@ module Redis::RedisKeys # Track conversation assignments to agents for rate limiting ASSIGNMENT_KEY = 'ASSIGNMENT::%d::AGENT::%d::CONVERSATION::%d'.freeze ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%d::AGENT::%d::*'.freeze + + ## Account Email Rate Limiting + ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze end diff --git a/spec/models/concerns/account_email_rate_limitable_spec.rb b/spec/models/concerns/account_email_rate_limitable_spec.rb new file mode 100644 index 000000000..919c5f621 --- /dev/null +++ b/spec/models/concerns/account_email_rate_limitable_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe AccountEmailRateLimitable do + let(:account) { create(:account) } + + describe '#email_rate_limit' do + it 'returns account-level override when set' do + account.update!(limits: { 'emails' => 50 }) + expect(account.email_rate_limit).to eq(50) + end + + it 'returns global config when no account override' do + InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200) + expect(account.email_rate_limit).to eq(200) + end + + it 'returns account override over global config' do + InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200) + account.update!(limits: { 'emails' => 50 }) + expect(account.email_rate_limit).to eq(50) + end + end + + describe '#within_email_rate_limit?' do + before do + account.update!(limits: { 'emails' => 2 }) + end + + it 'returns true when under limit' do + expect(account).to be_within_email_rate_limit + end + + it 'returns false when at limit' do + 2.times { account.increment_email_sent_count } + expect(account).not_to be_within_email_rate_limit + end + end + + describe '#increment_email_sent_count' do + it 'increments the counter' do + expect { account.increment_email_sent_count }.to change(account, :emails_sent_today).by(1) + end + + it 'sets TTL on first increment' do + key = format(Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, account_id: account.id, date: Time.zone.today.to_s) + allow(Redis::Alfred).to receive(:incr).and_return(1) + allow(Redis::Alfred).to receive(:expire) + + account.increment_email_sent_count + + expect(Redis::Alfred).to have_received(:expire).with(key, AccountEmailRateLimitable::OUTBOUND_EMAIL_TTL) + end + + it 'does not reset TTL on subsequent increments' do + allow(Redis::Alfred).to receive(:incr).and_return(2) + allow(Redis::Alfred).to receive(:expire) + + account.increment_email_sent_count + + expect(Redis::Alfred).not_to have_received(:expire) + end + end +end diff --git a/spec/services/messages/send_email_notification_service_spec.rb b/spec/services/messages/send_email_notification_service_spec.rb index 7c0970fe1..0c1563c79 100644 --- a/spec/services/messages/send_email_notification_service_spec.rb +++ b/spec/services/messages/send_email_notification_service_spec.rb @@ -99,6 +99,20 @@ describe Messages::SendEmailNotificationService do end end + context 'when account email rate limit is exceeded' do + let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + + before do + conversation.contact.update!(email: 'test@example.com') + allow_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance + end + + it 'does not enqueue job' do + expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob) + end + end + context 'when channel does not support email notifications' do let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) } let(:conversation) { create(:conversation, account: account, inbox: inbox) }