From 133fb1bcf621b80198d51e693def6e402552fcb5 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 2 Feb 2026 11:59:51 +0530 Subject: [PATCH 1/4] feat: add mark pending action to automation (#13378) --- .../dashboard/helper/validations.js | 1 + .../dashboard/i18n/locale/en/automation.json | 3 ++- .../settings/automation/constants.js | 21 +++++++++++++++++++ app/models/automation_rule.rb | 4 ++-- app/services/action_service.rb | 4 ++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/helper/validations.js b/app/javascript/dashboard/helper/validations.js index edebc4656..e425047a2 100644 --- a/app/javascript/dashboard/helper/validations.js +++ b/app/javascript/dashboard/helper/validations.js @@ -127,6 +127,7 @@ const validateSingleAction = action => { 'resolve_conversation', 'remove_assigned_team', 'open_conversation', + 'pending_conversation', ]; if ( diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 43245a1d5..341027299 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -150,7 +150,8 @@ "ADD_PRIVATE_NOTE": "Add a Private Note", "CHANGE_PRIORITY": "Change Priority", "ADD_SLA": "Add SLA", - "OPEN_CONVERSATION": "Open conversation" + "OPEN_CONVERSATION": "Open conversation", + "PENDING_CONVERSATION": "Mark conversation as pending" }, "MESSAGE_TYPES": { "INCOMING": "Incoming Message", diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index bc767040b..3acca3e2e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -116,6 +116,10 @@ export const AUTOMATIONS = { key: 'open_conversation', name: 'OPEN_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -232,6 +236,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -360,6 +368,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -482,6 +494,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'send_webhook_event', name: 'SEND_WEBHOOK_EVENT', @@ -668,6 +684,11 @@ export const AUTOMATION_ACTION_TYPES = [ label: 'OPEN_CONVERSATION', inputType: null, }, + { + key: 'pending_conversation', + label: 'PENDING_CONVERSATION', + inputType: null, + }, { key: 'send_webhook_event', label: 'SEND_WEBHOOK_EVENT', diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 9dc4d97eb..8162abb91 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -41,8 +41,8 @@ class AutomationRule < ApplicationRecord def actions_attributes %w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation - send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript - add_private_note].freeze + send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority + send_email_transcript add_private_note].freeze end def file_base_data diff --git a/app/services/action_service.rb b/app/services/action_service.rb index a50b11193..80caac392 100644 --- a/app/services/action_service.rb +++ b/app/services/action_service.rb @@ -22,6 +22,10 @@ class ActionService @conversation.open! end + def pending_conversation(_params) + @conversation.pending! + end + def change_status(status) @conversation.update!(status: status[0]) end From b686d140440804aa0475ec6e84ae47b5597be90b Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 2 Feb 2026 14:22:53 +0400 Subject: [PATCH 2/4] feat: Handle external echo messages from native apps (#13371) When businesses use WhatsApp Business App (co-existence mode) or Instagram App or TikTok alongside Chatwoot, messages sent from the native apps were not synced properly back to Chatwoot. This left agents with an incomplete conversation history and no visibility into responses sent outside the dashboard. Additionally, if these echo messages did arrive, they appeared as "Sent by: Bot" in the UI since they had no sender, making it confusing for agents. This PR subscribes to WhatsApp `smb_message_echoes` webhook events and routes them through the existing service with an `outgoing_echo` flag, mirroring how Instagram already handles echoes. On the Instagram side, echo messages now also carry the `external_echo` content attribute and `delivered` status. On the frontend, messages with `externalEcho` are distinguished from bot messages showing a "Native app" avatar and an advisory note encouraging agents to reply from Chatwoot to maintain the service window. CleanShot 2026-01-29 at 13 37 57@2x Fixes https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo Fixes https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling --- .../instagram/base_message_builder.rb | 2 + .../components-next/message/Message.vue | 25 +++++++- .../i18n/locale/en/conversation.json | 2 + app/jobs/webhooks/tiktok_events_job.rb | 2 +- app/jobs/webhooks/whatsapp_events_job.rb | 50 ++++++++++++++++ app/models/message.rb | 3 +- app/services/tiktok/message_service.rb | 6 +- app/services/whatsapp/facebook_api_client.rb | 3 +- .../whatsapp/incoming_message_base_service.rb | 60 ++++++++++++++----- .../incoming_message_service_helpers.rb | 10 ++-- .../whatsapp/facebook_api_client_spec.rb | 6 +- 11 files changed, 141 insertions(+), 28 deletions(-) diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 818c217ca..8045e84c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: message_type, + status: @outgoing_echo ? :delivered : :sent, source_id: message_identifier, content: message_content, sender: @outgoing_echo ? nil : contact, @@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil } } + params[:content_attributes][:external_echo] = true if @outgoing_echo params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params end diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index c4ae45fef..0f6ab85a8 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue'; import { useTimeoutFn } from '@vueuse/core'; import { provideMessageContext } from './provider.js'; import { useTrack } from 'dashboard/composables'; +import { useMapGetter } from 'dashboard/composables/store'; import { emitter } from 'shared/helpers/mitt'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import { LocalStorage } from 'shared/helpers/localStorage'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; +import { getInboxIconByType } from 'dashboard/helper/inbox'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_TYPES, @@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false); const showContextMenu = ref(false); const { t } = useI18n(); const route = useRoute(); +const inboxGetter = useMapGetter('inboxes/getInbox'); +const inbox = computed(() => inboxGetter.value(props.inboxId) || {}); /** * Computes the message variant based on props @@ -162,6 +166,10 @@ const variant = computed(() => { if (props.contentAttributes?.isUnsupported) return MESSAGE_VARIANTS.UNSUPPORTED; + if (props.contentAttributes?.externalEcho) { + return MESSAGE_VARIANTS.AGENT; + } + const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT; if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) { return MESSAGE_VARIANTS.BOT; @@ -424,6 +432,18 @@ function handleReplyTo() { } const avatarInfo = computed(() => { + if (props.contentAttributes?.externalEcho) { + const { name, avatar_url, channel_type, medium } = inbox.value; + const iconName = avatar_url + ? null + : getInboxIconByType(channel_type, medium); + return { + name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'), + src: avatar_url || '', + iconName, + }; + } + // If no sender, return bot info if (!props.sender) { return { @@ -451,6 +471,9 @@ const avatarInfo = computed(() => { }); const avatarTooltip = computed(() => { + if (props.contentAttributes?.externalEcho) { + return t('CONVERSATION.NATIVE_APP_ADVISORY'); + } if (avatarInfo.value.name === '') return ''; return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; }); @@ -484,7 +507,7 @@ provideMessageContext({
"Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json + body: { override_callback_uri: callback_url, verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return( status: 200, @@ -184,7 +185,8 @@ describe Whatsapp::FacebookApiClient do stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json + body: { override_callback_uri: callback_url, verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json) end From c77d935e385cb0b72b8ac66a114c1253b0d104fd Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 2 Feb 2026 15:20:35 +0400 Subject: [PATCH 3/4] fix: Subscribe app to WABA before overriding webhook callback URL (#13279) #### Problem Meta requires the app to be subscribed to the WABA before `override_callback_uri` can be used. The current implementation tries to use `override_callback_uri` directly, which fails with: > Error 100: "Before override the current callback uri, your app must be subscribed to receive messages for WhatsApp Business Account" This causes embedded signup to fail silently, the inbox appears connected but never receives messages. #### Solution Split `subscribe_waba_webhook` into two sequential API calls: ```ruby def subscribe_waba_webhook(waba_id, callback_url, verify_token) # Step 1: Subscribe app to WABA first (required before override) subscribe_app_to_waba(waba_id) # Step 2: Override callback URL for this specific WABA override_waba_callback(waba_id, callback_url, verify_token) end ``` #### References - Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks - Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url Co-authored-by: Sojan Jose --- app/services/whatsapp/facebook_api_client.rb | 21 ++++++++- .../whatsapp/facebook_api_client_spec.rb | 44 +++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 985c63f7f..fa09a4b44 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -61,6 +61,25 @@ class Whatsapp::FacebookApiClient end def subscribe_waba_webhook(waba_id, callback_url, verify_token) + # Step 1: Subscribe app to WABA first (required before override) + # Meta requires the app to be subscribed before using override_callback_uri + # See: https://github.com/chatwoot/chatwoot/issues/13097 + subscribe_app_to_waba(waba_id) + + # Step 2: Override callback URL for this specific WABA + override_waba_callback(waba_id, callback_url, verify_token) + end + + def subscribe_app_to_waba(waba_id) + response = HTTParty.post( + "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", + headers: request_headers + ) + + handle_response(response, 'App subscription to WABA failed') + end + + def override_waba_callback(waba_id, callback_url, verify_token) response = HTTParty.post( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", headers: request_headers, @@ -71,7 +90,7 @@ class Whatsapp::FacebookApiClient }.to_json ) - handle_response(response, 'Webhook subscription failed') + handle_response(response, 'Webhook callback override failed') end def unsubscribe_waba_webhook(waba_id) diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 51007332e..74fb2f6e2 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -161,6 +161,18 @@ describe Whatsapp::FacebookApiClient do context 'when successful' do before do + # Step 1: Subscribe app to WABA (no body) + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Step 2: Override callback URL (with body) stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, @@ -180,19 +192,45 @@ describe Whatsapp::FacebookApiClient do end end - context 'when failed' do + context 'when app subscription fails' do before do + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return(status: 400, body: { error: 'App subscription to WABA failed' }.to_json) + end + + it 'raises an error' do + expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/App subscription to WABA failed/) + end + end + + context 'when callback override fails' do + before do + # Step 1 succeeds + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Step 2 fails stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, body: { override_callback_uri: callback_url, verify_token: verify_token, subscribed_fields: %w[messages smb_message_echoes] }.to_json ) - .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json) + .to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json) end it 'raises an error' do - expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/) + expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook callback override failed/) end end end From c884cdefde7736b4de0f92da3594bbb9b8f4759d Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 3 Feb 2026 02:06:51 +0530 Subject: [PATCH 4/4] 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) }