feat: add per-account daily rate limit for outbound emails (#13411)
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 |
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user