Compare commits

...
Author SHA1 Message Date
Muhsin 949a728977 fix(inboxes): stop disabled continuity mail sends 2026-07-22 21:21:42 +04:00
Muhsin 2437da5339 fix(inboxes): keep disabled read receipts accurate 2026-07-22 18:20:54 +04:00
Muhsin 2abb7759f2 fix(inboxes): preserve disabled inbox status updates 2026-07-22 17:53:33 +04:00
Muhsin KelothandGitHub ca1069241f Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-22 17:39:15 +04:00
Muhsin a744b1e247 fix(sms): allow disabled inbox delivery callbacks 2026-07-22 17:20:37 +04:00
Muhsin KelothandGitHub 559a052e42 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-22 16:57:13 +04:00
Muhsin f855429d9a fix(inboxes): block disabled inbox side effects 2026-07-22 16:51:49 +04:00
Muhsin 97b30127a6 fix(inboxes): block disabled widget config and inbound calls 2026-07-22 16:20:41 +04:00
Muhsin 3a7c418139 fix(inboxes): block disabled inbox campaign and widget mutations 2026-07-22 15:39:17 +04:00
Muhsin f2e3e5a83f fix(inboxes): allow telegram edits for disabled inboxes 2026-07-22 15:11:21 +04:00
Muhsin 23fac0e7b1 feat(inboxes): show disabled badge on inbox names 2026-07-22 15:08:15 +04:00
Muhsin 02ff710709 fix(inboxes): address disabled inbox review feedback 2026-07-22 14:57:57 +04:00
Muhsin KelothandGitHub 3f9864c74f Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-22 14:14:06 +04:00
Muhsin KelothandGitHub 0b9b137723 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-15 14:20:46 +04:00
copilot-swe-agent[bot]andGitHub 42e392eca4 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-08 11:52:28 +00:00
Muhsin KelothandMuhsin e668f9da20 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-07-03 13:50:14 +04:00
Muhsin KelothandGitHub 65a78c7c20 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-25 10:10:44 +04:00
Muhsin 86d0955794 Update inbox_disabled.rb 2026-06-22 14:39:32 +04:00
Muhsin KelothandGitHub d720e013d4 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-22 13:41:51 +04:00
Muhsin 2f52af6e2d Update inbox.rb 2026-06-22 13:40:22 +04:00
Muhsin KelothandGitHub d59415ff5d Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-22 13:09:18 +04:00
Muhsin KelothandGitHub 14e4ec81c6 Merge branch 'develop' into codex/cw-4998-disable-inbox 2026-06-09 21:07:29 +04:00
Muhsin cbd24dbdb2 feat(inboxes): add inbox disable toggle 2026-06-09 21:07:05 +04:00
84 changed files with 880 additions and 31 deletions
@@ -3,6 +3,8 @@ class Campaigns::CampaignConversationBuilder
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
return unless @contact_inbox.inbox.active?
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
ActiveRecord::Base.transaction do
+2
View File
@@ -2,6 +2,8 @@ class ConversationBuilder
pattr_initialize [:params!, :contact_inbox!]
def perform
raise CustomExceptions::InboxDisabled unless @contact_inbox.inbox.active?
look_up_exising_conversation || create_new_conversation
end
@@ -20,6 +20,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def perform
# This channel might require reauthorization, may be owner might have changed the fb password
return if @inbox.channel.reauthorization_required?
return unless @inbox.active?
ActiveRecord::Base.transaction do
build_contact_inbox
@@ -10,6 +10,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
def perform
return if @inbox.channel.reauthorization_required?
return unless @inbox.active?
ActiveRecord::Base.transaction do
build_message
+3 -2
View File
@@ -22,14 +22,15 @@ class Messages::MessageBuilder
end
def perform
raise CustomExceptions::InboxDisabled unless @conversation.inbox.active?
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.save!
@message
@message.tap(&:save!)
end
private
@@ -1,5 +1,6 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
before_action :ensure_inbox_active, only: [:create, :retry]
def index
@messages = message_finder.perform
@@ -9,6 +10,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
user = Current.user || @resource
mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform
rescue CustomExceptions::InboxDisabled
render_inbox_disabled_error
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -32,6 +35,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue CustomExceptions::InboxDisabled
render_inbox_disabled_error
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -80,4 +85,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
def ensure_inbox_active
render_inbox_disabled_error unless @conversation.inbox.active?
end
end
@@ -198,6 +198,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
render_inbox_disabled_error unless @inbox.active?
end
def contact
@@ -214,6 +215,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
render_inbox_disabled_error unless @contact_inbox.inbox.active?
rescue ActiveRecord::RecordNotUnique
render json: { error: 'source_id should be unique' }, status: :unprocessable_entity
end
@@ -221,12 +223,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def build_contact_inbox
return if @inbox.blank? || @contact.blank?
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
ContactInboxBuilder.new(contact: @contact, inbox: @inbox, source_id: params[:source_id], hmac_verified: hmac_verified?).perform
end
def conversation_finder
@@ -193,7 +193,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
[:name, :avatar, :active, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
@@ -90,4 +90,8 @@ class Api::V1::Widget::BaseController < ApplicationController
message_type: :incoming
}
end
def ensure_inbox_active
render_inbox_disabled_error unless @web_widget.inbox.active?
end
end
@@ -1,4 +1,5 @@
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
before_action :set_global_config
def create
@@ -1,6 +1,7 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :ensure_inbox_active, only: [:update, :set_user, :destroy_custom_attributes]
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
@@ -1,6 +1,12 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
DISABLED_INBOX_ACTIONS = [
:create, :toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes, :transcript
].freeze
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
before_action :ensure_inbox_active, only: DISABLED_INBOX_ACTIONS
def index
@conversation = conversation
@@ -1,6 +1,7 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
include WebsiteTokenHelper
before_action :set_web_widget
before_action :ensure_inbox_active
before_action :set_contact
def create
@@ -8,4 +9,15 @@ class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsCon
super
end
private
def ensure_inbox_active
return if @web_widget.inbox.active?
render json: {
error: 'inbox_disabled',
message: 'This inbox is currently disabled'
}, status: :forbidden
end
end
@@ -1,6 +1,8 @@
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
before_action :ensure_inbox_active
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,
event_info: permitted_params[:event_info].to_h.merge(event_info))
@@ -1,4 +1,5 @@
class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
before_action :set_message
def add_participant_to_meeting
@@ -1,4 +1,6 @@
class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active
def create
if conversation.present? && label_defined_in_account?
conversation.label_list.add(permitted_params[:label])
@@ -1,4 +1,5 @@
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :ensure_inbox_active, only: [:create, :update]
before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
@@ -9,6 +9,7 @@ module RequestExceptionHandler
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from CustomExceptions::InboxDisabled, with: :render_inbox_disabled_error
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
@@ -45,6 +46,13 @@ module RequestExceptionHandler
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_inbox_disabled_error(_exception = nil)
render json: {
error: 'inbox_disabled',
message: 'This inbox is currently disabled'
}, status: :forbidden
end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
@@ -0,0 +1,34 @@
class Platform::Api::V1::InboxesController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :set_inbox
def disable
update_active_state(false)
end
def enable
update_active_state(true)
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def set_inbox
@inbox = @account.inboxes.find(params[:id])
end
def update_active_state(active)
@inbox.update!(active: active)
render json: { success: true, inbox_id: @inbox.id, active: @inbox.active }
end
end
@@ -1,5 +1,6 @@
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
before_action :contact_inbox, except: [:create]
before_action :ensure_inbox_active, only: [:create, :update]
before_action :process_hmac
def show; end
@@ -50,4 +51,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end
def ensure_inbox_active
render_inbox_disabled_error unless @inbox_channel.inbox.active?
end
end
@@ -1,6 +1,7 @@
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
include Events::Types
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
before_action :ensure_inbox_active, only: [:create, :toggle_typing, :toggle_status]
def index
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
@@ -64,4 +65,8 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
def conversation_params
params.permit(custom_attributes: {})
end
def ensure_inbox_active
render_inbox_disabled_error unless @contact_inbox.inbox.active?
end
end
@@ -1,5 +1,6 @@
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
before_action :set_message, only: [:update]
before_action :ensure_inbox_active, only: [:create, :update]
def index
@messages = @conversation.nil? ? [] : message_finder.perform
@@ -70,4 +71,8 @@ class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesCon
def check_csat_locked
(Time.zone.now.to_date - @message.created_at.to_date).to_i > 14 and @message.content_type == 'input_csat'
end
def ensure_inbox_active
render_inbox_disabled_error unless @conversation.inbox.active?
end
end
+5
View File
@@ -5,6 +5,7 @@ class WidgetsController < ActionController::Base
before_action :set_global_config
before_action :set_web_widget
before_action :ensure_account_is_active
before_action :ensure_inbox_active
before_action :ensure_location_is_supported
before_action :set_token
before_action :set_contact
@@ -62,6 +63,10 @@ class WidgetsController < ActionController::Base
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
end
def ensure_inbox_active
head :not_found unless @web_widget.inbox.active?
end
def ensure_location_is_supported; end
def additional_attributes
@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
@@ -7,13 +8,21 @@ defineProps({
default: () => {},
},
});
const { t } = useI18n();
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<div :title="inbox.name" class="flex items-center gap-1 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-body-main text-n-slate-11">
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-4 shrink-0 items-center rounded-md bg-n-alpha-2 px-1 text-label-mini font-medium text-n-slate-11"
>
{{ t('INBOX_MGMT.DISABLED') }}
</span>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
@@ -7,13 +8,21 @@ defineProps({
default: () => {},
},
});
const { t } = useI18n();
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<div :title="inbox.name" class="flex items-center gap-1 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-4 shrink-0 items-center rounded-md bg-n-alpha-2 px-1 text-label-mini font-medium text-n-slate-11"
>
{{ t('INBOX_MGMT.DISABLED') }}
</span>
</div>
</template>
@@ -4,6 +4,7 @@
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"COUNT": "{n} inbox | {n} inboxes",
"DISABLED": "Disabled",
"SEARCH_PLACEHOLDER": "Search inboxes...",
"NO_RESULTS": "No inboxes found matching your search",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
@@ -142,10 +142,20 @@ const openDelete = inbox => {
>
<ChannelIcon class="size-6 text-n-slate-10" :inbox="inbox" />
</div>
<div class="flex flex-col items-start gap-1">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<div class="flex flex-col items-start gap-1 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<span
class="block text-heading-3 text-n-slate-12 capitalize truncate"
>
{{ inbox.name }}
</span>
<span
v-if="inbox.active === false"
class="inline-flex h-5 shrink-0 items-center rounded-md bg-n-alpha-2 px-1.5 text-label-mini font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.DISABLED') }}
</span>
</div>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
+1
View File
@@ -4,6 +4,7 @@ class ConversationReplyEmailJob < ApplicationJob
def perform(conversation_id, last_queued_id)
conversation = Conversation.find(conversation_id)
return unless conversation.account.active?
return unless conversation.inbox.active?
if conversation.messages.incoming&.last&.content_type == 'incoming_email'
ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later
@@ -13,6 +13,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
def should_fetch_emails?(inbox)
return false if inbox.account.suspended?
return false unless inbox.active?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
+1 -1
View File
@@ -25,7 +25,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
private
def should_fetch_email?(channel)
channel.imap_enabled? && !channel.reauthorization_required?
channel.inbox.active? && channel.imap_enabled? && !channel.reauthorization_required?
end
def process_email_for_channel(channel, interval)
+58
View File
@@ -1,6 +1,8 @@
class SendReplyJob < ApplicationJob
queue_as :high
INBOX_DISABLED_ERROR = 'This inbox is currently disabled'.freeze
CHANNEL_SERVICES = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
@@ -18,6 +20,7 @@ class SendReplyJob < ApplicationJob
def perform(message_id)
message = Message.find(message_id)
channel_name = message.conversation.inbox.channel.class.to_s
return handle_disabled_inbox(message, channel_name) unless message.inbox.active?
return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
@@ -29,6 +32,61 @@ class SendReplyJob < ApplicationJob
private
def handle_disabled_inbox(message, channel_name)
return unless send_service_available?(channel_name)
return unless deliverable_reply?(message, channel_name)
mark_message_failed(message)
end
def send_service_available?(channel_name)
channel_name == 'Channel::FacebookPage' || CHANNEL_SERVICES.key?(channel_name)
end
def deliverable_reply?(message, channel_name)
return false unless deliverable_message?(message)
return message.email_notifiable_message? if email_channel?(channel_name)
return email_notification_deliverable?(message, channel_name) if email_notification_channel?(channel_name)
true
end
def deliverable_message?(message)
return false if message.private?
return false unless message.outgoing? || message.template?
return false if message.source_id.present?
return false if message.content_type == 'voice_call'
true
end
def email_channel?(channel_name)
channel_name == 'Channel::Email'
end
def email_notification_channel?(channel_name)
%w[Channel::WebWidget Channel::Api].include?(channel_name)
end
def email_notification_deliverable?(message, channel_name)
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_notification_enabled?(message.inbox, channel_name)
end
def email_notification_enabled?(inbox, channel_name)
return inbox.channel.continuity_via_email if channel_name == 'Channel::WebWidget'
return inbox.account.feature_enabled?('email_continuity_on_api_channel') if channel_name == 'Channel::Api'
false
end
def mark_message_failed(message)
Messages::StatusUpdateService.new(message, 'failed', INBOX_DISABLED_ERROR).perform
end
def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
+6 -4
View File
@@ -57,9 +57,11 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
next if channel.blank?
if (event_name = event_name(messaging))
send(event_name, messaging, channel)
end
event_name = event_name(messaging)
next if event_name.blank?
next unless channel.inbox.active? || event_name == :read
send(event_name, messaging, channel)
end
end
@@ -126,7 +128,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end
def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging, channel)
+1
View File
@@ -4,6 +4,7 @@ class Webhooks::LineEventsJob < ApplicationJob
def perform(params: {}, signature: '', post_body: '')
@params = params
return unless valid_event_payload?
return unless @channel.inbox.active?
return unless valid_post_body?(post_body, signature)
Line::IncomingMessageService.new(inbox: @channel.inbox, params: @params['line'].with_indifferent_access).perform
+5
View File
@@ -8,6 +8,7 @@ class Webhooks::SmsEventsJob < ApplicationJob
channel = Channel::Sms.find_by(phone_number: params[:to])
return unless channel
return if incoming_event?(params) && !channel.inbox.active?
process_event_params(channel, params)
end
@@ -25,4 +26,8 @@ class Webhooks::SmsEventsJob < ApplicationJob
def delivery_event?(params)
params[:type] == 'message-delivered' || params[:type] == 'message-failed'
end
def incoming_event?(params)
params[:type] == 'message-received'
end
end
+7 -2
View File
@@ -6,7 +6,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob
channel = Channel::Telegram.find_by(bot_token: params[:bot_token])
if channel_is_inactive?(channel)
if channel_is_inactive?(channel, params)
log_inactive_channel(channel, params)
return
end
@@ -16,13 +16,18 @@ class Webhooks::TelegramEventsJob < ApplicationJob
private
def channel_is_inactive?(channel)
def channel_is_inactive?(channel, params)
return true if channel.blank?
return true unless channel.account.active?
return true unless channel.inbox.active? || update_message_event?(params)
false
end
def update_message_event?(params)
params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present?
end
def log_inactive_channel(channel, params)
message = if channel&.id
"Account #{channel.account.id} is not active for channel #{channel.id}"
+1
View File
@@ -21,6 +21,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
def channel_is_inactive?
return true if channel.blank?
return true unless channel.account.active?
return true unless channel.inbox.active? || event_name == 'im_mark_read_msg'
false
end
+16 -2
View File
@@ -8,7 +8,7 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
if channel_is_inactive?(channel)
if channel_is_inactive?(channel, params)
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
@@ -124,15 +124,29 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
].compact_blank.first
end
def channel_is_inactive?(channel)
def channel_is_inactive?(channel, params)
return true if channel.blank?
# Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
return true unless channel.account.active?
return true unless channel.inbox.active? || existing_message_update_event?(params)
false
end
def status_update_event?(params)
value = params.dig(:entry, 0, :changes, 0, :value) || params
value[:statuses].present?
end
def existing_message_update_event?(params)
status_update_event?(params) || call_event?(params)
end
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def embedded_signup_channel?(channel)
(channel.provider_config || {}).to_h['source'] == 'embedded_signup'
end
+2
View File
@@ -63,6 +63,8 @@ class AgentBotListener < BaseListener
private
def agent_bots_for(inbox, conversation = nil)
return [] unless inbox.active?
bots = []
bots << conversation.assignee_agent_bot if conversation&.assignee_agent_bot.present?
inbox_bot = active_inbox_agent_bot(inbox)
+8 -2
View File
@@ -18,6 +18,7 @@ class AutomationRuleListener < BaseListener
def message_created(event)
message = event.data[:message]
return unless message.inbox.active?
return if ignore_message_created_event?(event)
account = message.try(:account)
@@ -39,10 +40,11 @@ class AutomationRuleListener < BaseListener
def process_conversation_event(event, event_name)
return if performed_by_automation?(event)
auto_reply_skip_events = %w[conversation_created conversation_opened]
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
return if auto_reply_event?(event, event_name)
conversation = event.data[:conversation]
return unless conversation.inbox.active?
account = conversation.account
changed_attributes = event.data[:changed_attributes]
@@ -74,6 +76,10 @@ class AutomationRuleListener < BaseListener
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
end
def auto_reply_event?(event, event_name)
%w[conversation_created conversation_opened].include?(event_name) && ignore_auto_reply_event?(event)
end
def ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
conversation.additional_attributes['auto_reply'].present?
+1
View File
@@ -3,6 +3,7 @@ class CsatSurveyListener < BaseListener
conversation = extract_conversation_and_account(event)[0]
return unless conversation.resolved?
return unless conversation.inbox.active?
CsatSurveyService.new(conversation: conversation).perform
end
+1
View File
@@ -6,6 +6,7 @@ class ReplyMailbox < ApplicationMailbox
def process
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
return unless @conversation.inbox.active?
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
+1
View File
@@ -58,6 +58,7 @@ class Campaign < ApplicationRecord
def trigger!
return unless one_off?
return unless feature_enabled?
return unless inbox.active?
return unless mark_processing!
execute_campaign
@@ -7,6 +7,8 @@ class Line::IncomingMessageService
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
def perform
return unless @inbox.active?
# probably test events
return if params[:events].blank?
@@ -22,6 +22,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
return nil unless @channel # No valid channel found
return nil unless @inbox.active?
return nil unless incoming_email_from_valid_email? # Skip edge cases
# Check if conversation already exists by in_reply_to
@@ -4,6 +4,8 @@ class Sms::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
return unless @inbox.active?
set_contact
set_conversation
@message = @conversation.messages.create!(
@@ -7,6 +7,8 @@ class Telegram::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
return unless @inbox.active?
# chatwoot doesn't support group conversations at the moment
transform_business_message!
return unless private_message?
+2
View File
@@ -4,6 +4,8 @@ class Tiktok::MessageService
pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform
return unless channel.inbox.active?
if outgoing_message?
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
@@ -7,6 +7,7 @@ class Twilio::IncomingMessageService
def perform
return if twilio_channel.blank?
return unless inbox.active?
set_contact
set_conversation
@@ -5,6 +5,8 @@ class Twitter::DirectMessageParserService < Twitter::WebhooksBaseService
return if source_app_id == parent_app_id
set_inbox
return unless @inbox.active?
ensure_contacts
set_conversation
@message = @conversation.messages.create!(
@@ -4,6 +4,7 @@ class Twitter::TweetParserService < Twitter::WebhooksBaseService
def perform
set_inbox
return unless @inbox.active?
return if !tweets_enabled? || message_already_exist? || user_has_blocked?
create_message
@@ -13,6 +13,8 @@ class Whatsapp::IncomingMessageBaseService
if processed_params.try(:[], :statuses).present?
process_statuses
elsif messages_data.present?
return unless @inbox.active?
process_messages
end
end
@@ -2,6 +2,7 @@ json.id resource.id
json.avatar_url resource.try(:avatar_url)
json.channel_id resource.channel_id
json.name resource.name
json.active resource.active
json.channel_type resource.channel_type
json.greeting_enabled resource.greeting_enabled
json.greeting_message resource.greeting_message
+6
View File
@@ -581,6 +581,12 @@ Rails.application.routes.draw do
end
end
resources :email_channel_migrations, only: [:create]
resources :inboxes, only: [] do
member do
post :disable
post :enable
end
end
end
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddActiveToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :active, :boolean, default: true, null: false
end
end
+1
View File
@@ -1048,6 +1048,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.boolean "enable_email_collect", default: true
t.boolean "csat_survey_enabled", default: false
t.boolean "allow_messages_after_resolved", default: true
t.boolean "active", default: true, null: false
t.jsonb "auto_assignment_config", default: {}
t.boolean "lock_to_single_conversation", default: false, null: false
t.bigint "portal_id"
@@ -1,6 +1,7 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_call_context, only: :initiate
before_action :ensure_inbox_active, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
@@ -96,6 +97,10 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_inbox_active
render_inbox_disabled_error unless @inbox.active?
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
@@ -90,10 +90,10 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
# A fresh contact-initiated leg on an inbox that cannot receive calls.
# Reject it so no conference, conversation, or Call row is created.
def reject_inbound?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && (!inbox.active? || !inbox.channel.inbound_calls_enabled?)
end
def reject_twiml
@@ -16,6 +16,7 @@ class Voice::OutboundCallBuilder
def perform!
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
raise CustomExceptions::InboxDisabled unless inbox.active?
# Claim for the caller if a reused conversation is unassigned at trigger time; wins over auto-assignment.
# New conversations set the assignee at creation instead (see create_conversation!).
@@ -75,7 +75,7 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
unless inbox.channel.inbound_calls_enabled?
unless inbox.active? && inbox.channel.inbound_calls_enabled?
Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
inbox.channel.provider_service.reject_call(payload[:id])
return
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class CustomExceptions::InboxDisabled < CustomExceptions::Base
def initialize(data = {})
super
end
def message
'This inbox is currently disabled'
end
def error_code
'inbox_disabled'
end
def http_status
:forbidden
end
end
@@ -31,6 +31,18 @@ RSpec.describe '/api/v1/widget/config', type: :request do
response_data = response.parsed_body
expect(response_data.keys).to include(*response_keys)
end
it 'does not initialize config or create a contact when the inbox is disabled' do
web_widget.inbox.update!(active: false)
expect do
post '/api/v1/widget/config',
params: params,
as: :json
end.not_to change(Contact, :count)
expect(response).to have_http_status(:forbidden)
end
end
context 'with correct website token and valid X-Auth-Token' do
@@ -48,6 +60,17 @@ RSpec.describe '/api/v1/widget/config', type: :request do
expect(response_data['contact']['pubsub_token']).to eq(contact_inbox.pubsub_token)
end
it 'does not initialize config when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post '/api/v1/widget/config',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
end
it 'returns 401 if account is suspended' do
account.update!(status: :suspended)
@@ -37,6 +37,18 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
expect(ContactIdentifyAction).to have_received(:new).with(expected_params)
expect(identify_action).to have_received(:perform)
end
it 'does not update the contact when the inbox is disabled' do
web_widget.inbox.update!(active: false)
patch '/api/v1/widget/contact',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(ContactIdentifyAction).not_to have_received(:new)
end
end
context 'with update contact' do
@@ -255,6 +267,18 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'does not set user when the inbox is disabled' do
web_widget.inbox.update!(active: false)
patch '/api/v1/widget/contact/set_user',
params: params.merge(identifier_hash: correct_identifier_hash),
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(ContactIdentifyAction).not_to have_received(:new)
end
end
end
@@ -263,19 +287,32 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
context 'with invalid website token' do
it 'returns unauthorized' do
post '/api/v1/widget/destroy_custom_attributes', params: { website_token: '' }
post '/api/v1/widget/contact/destroy_custom_attributes', params: { website_token: '' }
expect(response).to have_http_status(:not_found)
end
end
context 'with correct website token' do
it 'calls destroy custom attributes' do
post '/api/v1/widget/destroy_custom_attributes',
post '/api/v1/widget/contact/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(contact.reload.custom_attributes).to eq({})
end
it 'does not destroy custom attributes when the inbox is disabled' do
contact.update!(custom_attributes: { 'test' => 'value' })
web_widget.inbox.update!(active: false)
post '/api/v1/widget/contact/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(contact.reload.custom_attributes).to eq('test' => 'value')
end
end
end
end
@@ -43,6 +43,18 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(json_response['id']).to eq(conversation.display_id)
expect(json_response['status']).to eq(conversation.status)
end
it 'returns the conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end
end
context 'with a conversation but invalid source id' do
@@ -62,6 +74,19 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end
describe 'POST /api/v1/widget/conversations' do
it 'does not create a conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
expect do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: conversation_params,
as: :json
end.not_to change(Conversation, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates a conversation with correct details' do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
@@ -244,11 +269,40 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
it 'updates last seen when the inbox is disabled' do
current_time = DateTime.now.utc
allow(DateTime).to receive(:now).and_return(current_time)
web_widget.inbox.update!(active: false)
expect(Conversations::UpdateMessageStatusJob).to receive(:perform_later).with(conversation.id, current_time)
post '/api/v1/widget/conversations/update_last_seen',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
end
end
describe 'POST /api/v1/widget/conversations/transcript' do
context 'with a conversation' do
it 'does not send transcript email when the inbox is disabled' do
web_widget.inbox.update!(active: false)
contact.update!(email: 'test@test.com')
expect(ConversationReplyMailer).not_to receive(:with)
post '/api/v1/widget/conversations/transcript',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:forbidden)
end
it 'sends transcript email' do
contact.update(email: 'test@test.com')
mailer = double
@@ -34,6 +34,24 @@ RSpec.describe '/api/v1/widget/direct_uploads', type: :request do
json_response = response.parsed_body
expect(json_response['content_type']).to eq('image/png')
end
it 'does not create direct upload when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post api_v1_widget_direct_uploads_url,
params: {
website_token: web_widget.website_token,
blob: {
filename: 'avatar.png',
byte_size: '1234',
checksum: 'dsjbsdhbfif3874823mnsdbf',
content_type: 'image/png'
}
},
headers: { 'X-Auth-Token' => token }
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -34,6 +34,20 @@ RSpec.describe '/api/v1/widget/events', type: :request do
.with(params[:name], anything, contact_inbox: contact_inbox,
event_info: { test_id: 'test', browser_language: nil, widget_language: nil, browser: anything })
end
it 'does not dispatch events when the inbox is disabled' do
token
web_widget.inbox.update!(active: false)
expect(Rails.configuration.dispatcher).not_to receive(:dispatch)
post '/api/v1/widget/events',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -70,6 +70,17 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
}
)
end
it 'does not add a participant when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post add_participant_to_meeting_api_v1_widget_integrations_dyte_url,
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token, message_id: integration_message.id },
as: :json
expect(response).to have_http_status(:forbidden)
end
end
end
end
@@ -39,6 +39,18 @@ RSpec.describe '/api/v1/widget/labels', type: :request do
expect(conversation.reload.label_list.count).to eq 1
expect(conversation.reload.label_list.first).to eq 'customer-support'
end
it 'does not add labels when the inbox is disabled' do
web_widget.inbox.update!(active: false)
post '/api/v1/widget/labels',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(conversation.reload.label_list.count).to eq 0
end
end
context 'with invalid website token' do
@@ -67,6 +79,18 @@ RSpec.describe '/api/v1/widget/labels', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.reload.label_list.count).to eq 0
end
it 'does not remove labels when the inbox is disabled' do
web_widget.inbox.update!(active: false)
delete "/api/v1/widget/labels/#{params[:label]}",
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(conversation.reload.label_list.count).to eq 1
end
end
context 'with invalid website token' do
@@ -28,6 +28,18 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
expect(json_response['meta']).not_to be_empty
end
it 'returns messages when the inbox is disabled' do
web_widget.inbox.update!(active: false)
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload'].length).to eq(4)
end
it 'returns empty messages', :skip_before do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
@@ -43,6 +55,20 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
describe 'POST /api/v1/widget/messages' do
context 'when post request is made' do
it 'does not create message in conversation when the inbox is disabled' do
web_widget.inbox.update!(active: false)
message_params = { content: 'hello world', timestamp: Time.current }
expect do
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
end.not_to change(Message, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates message in conversation' do
conversation.destroy! # Test all params
message_params = { content: 'hello world', timestamp: Time.current }
@@ -213,6 +239,21 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
end
describe 'PUT /api/v1/widget/messages' do
context 'when the inbox is disabled' do
it 'does not update the message' do
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
web_widget.inbox.update!(active: false)
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: { email: Faker::Internet.email } },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(message.reload.submitted_email).to be_nil
end
end
context 'when put request targets a message from another visitor in the same inbox' do
it 'does not update the foreign message' do
other_contact = create(:contact, account: account, email: nil)
@@ -139,5 +139,18 @@ RSpec.describe 'Public Inbox Contact Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to eq contact_last_seen_at
end
it 'updates the last seen when the inbox is disabled' do
current_time = DateTime.now.utc
allow(DateTime).to receive(:now).and_return(current_time)
api_channel.inbox.update!(active: false)
expect(Conversations::UpdateMessageStatusJob).to receive(:perform_later).with(conversation.id, current_time)
post update_last_seen_path
expect(response).to have_http_status(:success)
expect(conversation.reload.contact_last_seen_at).not_to be_nil
end
end
end
@@ -208,6 +208,18 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
end
it 'returns 403 before initiating the provider call when the inbox is disabled' do
inbox.update!(active: false)
allow(provider_service).to receive(:initiate_call)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:forbidden)
expect(provider_service).not_to have_received(:initiate_call)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
@@ -129,6 +129,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
end
it 'rejects the inbound contact leg without building a call when the inbox is disabled' do
inbox.update!(active: false)
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
expect do
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
end.not_to change(Call, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
end
end
describe 'POST /twilio/voice/status/:phone' do
@@ -127,5 +127,20 @@ RSpec.describe Voice::OutboundCallBuilder do
)
end.to raise_error(ArgumentError, 'Agent required')
end
it 'raises before initiating the provider call when the inbox is disabled' do
inbox.update!(active: false)
expect do
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to raise_error(CustomExceptions::InboxDisabled)
expect(channel).not_to have_received(:initiate_call)
end
end
end
@@ -44,6 +44,19 @@ describe Whatsapp::IncomingCallService do
end
end
context 'when the inbox is disabled' do
it 'rejects the call with Meta without creating a Call or Conversation' do
inbox.update!(active: false)
provider_service = instance_double(Whatsapp::Providers::WhatsappCloudService, reject_call: true)
allow(inbox.channel).to receive(:provider_service).and_return(provider_service)
params = call_payload(event: 'connect', session: { sdp: "v=0\r\n...sdp...", sdp_type: 'offer' })
expect { described_class.new(inbox: inbox, params: params).perform }
.to not_change(Call, :count).and not_change(Conversation, :count)
expect(provider_service).to have_received(:reject_call).with(provider_call_id)
end
end
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
@@ -30,4 +30,12 @@ RSpec.describe ConversationReplyEmailJob, type: :job do
described_class.perform_now(conversation.id, 123)
expect(mailer).to have_received(:reply_without_summary)
end
it 'does not send an email when the inbox is disabled before the job runs' do
conversation.inbox.update!(active: false)
described_class.perform_now(conversation.id, 123)
expect(ConversationReplyMailer).not_to have_received(:with)
end
end
+57
View File
@@ -122,5 +122,62 @@ RSpec.describe SendReplyJob do
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
end
it 'marks the message as failed when the inbox is disabled before the queued send runs' do
twilio_channel = create(:channel_twilio_sms)
message = create(:message, message_type: :outgoing, conversation: create(:conversation, inbox: twilio_channel.inbox))
twilio_channel.inbox.update!(active: false)
expect(Twilio::SendOnTwilioService).not_to receive(:new)
described_class.perform_now(message.id)
expect(message.reload).to be_failed
expect(message.external_error).to eq('This inbox is currently disabled')
end
it 'does not mark incoming messages as failed when the inbox is disabled' do
twilio_channel = create(:channel_twilio_sms)
message = create(:message, message_type: :incoming, conversation: create(:conversation, inbox: twilio_channel.inbox))
twilio_channel.inbox.update!(active: false)
described_class.perform_now(message.id)
expect(message.reload).not_to be_failed
expect(message.external_error).to be_nil
end
it 'does not mark private notes as failed when the inbox is disabled' do
twilio_channel = create(:channel_twilio_sms)
message = create(:message, private: true, conversation: create(:conversation, inbox: twilio_channel.inbox))
twilio_channel.inbox.update!(active: false)
described_class.perform_now(message.id)
expect(message.reload).not_to be_failed
expect(message.external_error).to be_nil
end
it 'does not mark provider echo messages as failed when the inbox is disabled' do
twilio_channel = create(:channel_twilio_sms)
message = create(:message, source_id: 'provider-message-id', conversation: create(:conversation, inbox: twilio_channel.inbox))
twilio_channel.inbox.update!(active: false)
described_class.perform_now(message.id)
expect(message.reload).not_to be_failed
expect(message.external_error).to be_nil
end
it 'does not mark web widget replies as failed when email continuity is disabled' do
webwidget_channel = create(:channel_widget, continuity_via_email: false)
message = create(:message, message_type: :outgoing, conversation: create(:conversation, inbox: webwidget_channel.inbox))
webwidget_channel.inbox.update!(active: false)
described_class.perform_now(message.id)
expect(message.reload).not_to be_failed
expect(message.external_error).to be_nil
end
end
end
@@ -202,6 +202,24 @@ describe Webhooks::InstagramEventsJob do
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles messaging_seen callback when the inbox is disabled' do
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
instagram_messenger_inbox.update!(active: false)
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'does not create message callbacks when the inbox is disabled' do
dm_event = build(:instagram_message_create_event).with_indifferent_access
instagram_messenger_inbox.update!(active: false)
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 0
end
it 'handles unsupported message' do
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
+22
View File
@@ -53,6 +53,14 @@ RSpec.describe Webhooks::SmsEventsJob do
described_class.perform_now(params)
end
it 'does not call Sms::IncomingMessageService for received messages when the inbox is disabled' do
sms_channel.inbox.update!(active: false)
expect(Sms::IncomingMessageService).not_to receive(:new)
described_class.perform_now(params)
end
it 'calls Sms::DeliveryStatusService if the message type is message-delivered' do
params[:type] = 'message-delivered'
process_service = double
@@ -64,6 +72,20 @@ RSpec.describe Webhooks::SmsEventsJob do
described_class.perform_now(params)
end
it 'calls Sms::DeliveryStatusService for delivered messages when the inbox is disabled' do
sms_channel.inbox.update!(active: false)
params[:type] = 'message-delivered'
process_service = double
allow(Sms::DeliveryStatusService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Sms::DeliveryStatusService).to receive(:new).with(channel: sms_channel,
params: params[:message].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params)
end
it 'calls Sms::DeliveryStatusService if the message type is message-failed' do
params[:type] = 'message-failed'
process_service = double
@@ -46,6 +46,14 @@ RSpec.describe Webhooks::TelegramEventsJob do
expect(Telegram::IncomingMessageService).not_to receive(:new)
described_class.perform_now(params.with_indifferent_access)
end
it 'does not process incoming message events when the inbox is disabled' do
telegram_channel.inbox.update!(active: false)
expect(Telegram::IncomingMessageService).not_to receive(:new)
described_class.perform_now(params.with_indifferent_access)
end
end
context 'when update message params' do
@@ -60,5 +68,17 @@ RSpec.describe Webhooks::TelegramEventsJob do
expect(process_service).to receive(:perform)
described_class.perform_now(params.with_indifferent_access)
end
it 'calls Telegram::UpdateMessageService when the inbox is disabled' do
telegram_channel.inbox.update!(active: false)
process_service = double
allow(Telegram::UpdateMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Telegram::UpdateMessageService).to receive(:new).with(inbox: telegram_channel.inbox,
params: params['telegram'].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params.with_indifferent_access)
end
end
end
@@ -42,6 +42,38 @@ RSpec.describe Webhooks::TiktokEventsJob do
expect(read_status_service).to have_received(:perform)
end
it 'processes im_mark_read_msg events when the inbox is disabled' do
channel.inbox.update!(active: false)
read_status_service = instance_double(Tiktok::ReadStatusService, perform: true)
allow(Tiktok::ReadStatusService).to receive(:new).and_return(read_status_service)
event = {
event: 'im_mark_read_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1', read: { last_read_timestamp: 1_700_000_000_000 }, from_user: { id: 'user-1' } }.to_json
}
job.perform(event)
expect(Tiktok::ReadStatusService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
expect(read_status_service).to have_received(:perform)
end
it 'does not process im_receive_msg events when the inbox is disabled' do
channel.inbox.update!(active: false)
allow(Tiktok::MessageService).to receive(:new)
event = {
event: 'im_receive_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
it 'ignores unsupported event types' do
allow(Tiktok::MessageService).to receive(:new)
@@ -106,6 +106,55 @@ RSpec.describe Webhooks::WhatsappEventsJob do
job.perform_now(phone_number: unknown_phone)
end
it 'processes status callbacks when the inbox is disabled' do
wb_params = params.deep_dup
wb_params[:entry].first[:changes].first[:value][:statuses] = [
{ id: 'wamid-test', recipient_id: '919745786257', status: 'delivered' }
]
channel.inbox.update!(active: false)
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'does not process message callbacks when the inbox is disabled' do
wb_params = params.deep_dup
wb_params[:entry].first[:changes].first[:value][:messages] = [
{ from: '919745786257', id: 'wamid-test', text: { body: 'Hello' }, type: 'text' }
]
channel.inbox.update!(active: false)
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new)
job.perform_now(wb_params)
end
it 'processes call callbacks when the inbox is disabled' do
wb_params = params.deep_dup
wb_params[:entry].first[:changes].first[:field] = 'calls'
wb_params[:entry].first[:changes].first[:value][:calls] = [
{ id: 'wacid-test', from: '919745786257', event: 'connect', session: { sdp_type: 'offer' } }
]
channel.inbox.update!(active: false)
allow(Whatsapp::IncomingCallService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingCallService).to receive(:new).with(
inbox: channel.inbox,
params: {
calls: [wb_params[:entry].first[:changes].first[:value][:calls].first],
contacts: nil
}
)
job.perform_now(wb_params)
end
it 'uses from_user_id as the mutex sender for BSUID-only inbound messages' do
bsuid = 'IN.2081978709342942'
wb_params = params.deep_dup
@@ -52,6 +52,14 @@ describe CsatSurveyListener do
listener.conversation_status_changed(event)
end
it 'does not trigger CSAT survey service when the inbox is disabled' do
resolved_conversation.inbox.update!(active: false)
event = Events::Base.new(event_name, Time.zone.now, conversation: resolved_conversation)
expect(CsatSurveyService).not_to receive(:new)
listener.conversation_status_changed(event)
end
end
context 'when conversation is not resolved' do
+32
View File
@@ -42,6 +42,17 @@ RSpec.describe ReplyMailbox do
end
end
context 'with reply uuid present for a disabled inbox' do
before do
conversation.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
conversation.inbox.update!(active: false)
end
it 'does not add the mail content as a new message' do
expect { described_subject }.not_to change(Message, :count)
end
end
context 'with in reply to email' do
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:described_subject) { described_class.receive reply_mail_without_uuid }
@@ -67,6 +78,27 @@ RSpec.describe ReplyMailbox do
end
end
context 'when forwarded email inbox is disabled' do
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:forwarded_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
before do
email_channel.inbox.update!(active: false)
end
it 'does not create contacts, conversations, or messages' do
contact_count = Contact.count
conversation_count = Conversation.count
message_count = Message.count
described_class.receive forwarded_mail
expect(Contact.count).to eq(contact_count)
expect(Conversation.count).to eq(conversation_count)
expect(Message.count).to eq(message_count)
end
end
context 'when new conversation email contains null bytes' do
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:null_byte_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
+11
View File
@@ -142,6 +142,17 @@ RSpec.describe Campaign do
campaign.trigger!
end
it 'does not trigger the campaign when the inbox is disabled' do
campaign.save!
twilio_inbox.update!(active: false)
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
campaign.trigger!
expect(campaign.reload.active?).to be true
end
it 'keeps the campaign processing when triggering fails' do
campaign.save!
sms_service = double
@@ -252,6 +252,18 @@ describe Whatsapp::IncomingMessageService do
expect(message.reload.status).to eq('read')
end
it 'updates message status when the inbox is disabled' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'read' }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
whatsapp_channel.inbox.update!(active: false)
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('read')
end
it 'stores BSUID source ids from status contacts' do
bsuid = 'IN.2081978709342942'
parent_bsuid = 'IN.ENT.9081726354'