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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ class Account < ApplicationRecord
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
include CaptainFeaturable
|
||||
include AccountEmailRateLimitable
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
|
||||
49
app/models/concerns/account_email_rate_limitable.rb
Normal file
49
app/models/concerns/account_email_rate_limitable.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}?")
|
||||
|
||||
@@ -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 ------- #
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,4 +49,7 @@ module Redis::RedisKeys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
|
||||
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
end
|
||||
|
||||
63
spec/models/concerns/account_email_rate_limitable_spec.rb
Normal file
63
spec/models/concerns/account_email_rate_limitable_spec.rb
Normal file
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user