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/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/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({
{ '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) }
diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb
index 308d61f62..74fb2f6e2 100644
--- a/spec/services/whatsapp/facebook_api_client_spec.rb
+++ b/spec/services/whatsapp/facebook_api_client_spec.rb
@@ -161,10 +161,23 @@ 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' },
- 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,
@@ -179,18 +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' },
- body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }
)
- .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_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(/Webhook subscription failed/)
+ 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 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 callback override failed/)
end
end
end