chore: Migrate mailers from the worker to jobs (#12331)

Previously, email replies were handled inside workers. There was no
execution logs. This meant if emails silently failed (as reported by a
customer), we had no way to trace where the issue happened, the only
assumption was “no error = mail sent.”

By moving email handling into jobs, we now have proper execution logs
for each attempt. This makes it easier to debug delivery issues and
would have better visibility when investigating customer reports.

Fixes
https://linear.app/chatwoot/issue/CW-5538/emails-are-not-sentdelivered-to-the-contact

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Pranav
2025-10-21 16:36:37 -07:00
committed by GitHub
co-authored by Sojan Jose Shivam Mishra
parent b4c4f328b2
commit 254d5dcf9a
13 changed files with 446 additions and 165 deletions
+19 -17
View File
@@ -1,27 +1,29 @@
class SendReplyJob < ApplicationJob
queue_as :high
CHANNEL_SERVICES = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
'Channel::Email' => ::Email::SendOnEmailService,
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
'Channel::Api' => ::Messages::SendEmailNotificationService
}.freeze
def perform(message_id)
message = Message.find(message_id)
conversation = message.conversation
channel_name = conversation.inbox.channel.class.to_s
channel_name = message.conversation.inbox.channel.class.to_s
services = {
'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService,
'Channel::TwilioSms' => ::Twilio::SendOnTwilioService,
'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService
}
return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage'
case channel_name
when 'Channel::FacebookPage'
send_on_facebook_page(message)
else
services[channel_name].new(message: message).perform if services[channel_name].present?
end
service_class = CHANNEL_SERVICES[channel_name]
return unless service_class
service_class.new(message: message).perform
end
private
+1 -2
View File
@@ -39,8 +39,7 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation)
@message = message
reply_mail_object = prepare_mail(true)
message.update(source_id: reply_mail_object.message_id)
prepare_mail(true)
end
def conversation_transcript(conversation, to_email)
-43
View File
@@ -300,7 +300,6 @@ class Message < ApplicationRecord
def execute_after_create_commit_callbacks
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
reopen_conversation
notify_via_mail
set_conversation_activity
dispatch_create_events
send_reply
@@ -386,48 +385,6 @@ class Message < ApplicationRecord
::MessageTemplates::HookExecutionService.new(message: self).perform
end
def email_notifiable_webwidget?
inbox.web_widget? && inbox.channel.continuity_via_email
end
def email_notifiable_api_channel?
inbox.api? && inbox.account.feature_enabled?('email_continuity_on_api_channel')
end
def email_notifiable_channel?
email_notifiable_webwidget? || %w[Email].include?(inbox.inbox_type) || email_notifiable_api_channel?
end
def can_notify_via_mail?
return unless email_notifiable_message?
return unless email_notifiable_channel?
return if conversation.contact.email.blank?
true
end
def notify_via_mail
return unless can_notify_via_mail?
trigger_notify_via_mail
end
def trigger_notify_via_mail
return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email'
# will set a redis key for the conversation so that we don't need to send email for every new message
# last few messages coupled together is sent every 2 minutes rather than one email for each message
# if redis key exists there is an unprocessed job that will take care of delivering the email
return if Redis::Alfred.get(conversation_mail_key).present?
Redis::Alfred.setex(conversation_mail_key, id)
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id)
end
def conversation_mail_key
format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
end
def validate_attachments_limit(_attachment)
errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS
end
@@ -0,0 +1,18 @@
class Email::SendOnEmailService < Base::SendOnChannelService
private
def channel_class
Channel::Email
end
def perform_reply
return unless message.email_notifiable_message?
reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}")
message.update(source_id: reply_mail.message_id)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
end
@@ -0,0 +1,38 @@
class Messages::SendEmailNotificationService
pattr_initialize [:message!]
def perform
return unless should_send_email_notification?
conversation = message.conversation
conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
# Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
# the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
# the worker never manages to clean up.
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, message.id)
end
private
def should_send_email_notification?
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
email_reply_enabled?
end
def email_reply_enabled?
inbox = message.inbox
case inbox.channel.class.to_s
when 'Channel::WebWidget'
inbox.channel.continuity_via_email
when 'Channel::Api'
inbox.account.feature_enabled?('email_continuity_on_api_channel')
else
false
end
end
end
-16
View File
@@ -1,16 +0,0 @@
class EmailReplyWorker
include Sidekiq::Worker
sidekiq_options queue: :mailers, retry: 3
def perform(message_id)
message = Message.find(message_id)
return unless message.email_notifiable_message?
# send the email
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
end