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