Files
moreminimore-chat/app/mailers/conversation_reply_mailer.rb
Vaibhav Dewangan ecb7a44f07 fix(email): resolve reply recipients from the message being sent (#15194)
When an agent loops a new address into an ongoing email thread and then
adds a private note, the reply currently goes out with no Cc, and with
To falling back to the conversation contact. The newly added person
never receives the mail. This change makes an outgoing email keep the
To/Cc/Bcc that were entered on that reply, no matter what is added to
the conversation afterwards.

Closes #15193

## How to reproduce

1. Open a conversation on an Email inbox.
2. Add a new address to **Cc** and send a reply.
3. Immediately add a private note to the same conversation.
4. Inspect the delivered mail: `Cc` is empty and `To` is the
conversation contact instead of the addresses entered on the reply.

## What changed

`ConversationReplyMailer#cc_bcc_emails` and
`#to_emails_from_content_attributes` read the addresses from
`@conversation.messages.outgoing.last` rather than from the message they
were handed. Replies are delivered asynchronously
(`SendReplyJob.perform_later`, and `wait: 2.seconds` when the message
has attachments), so any outgoing message created in that window
replaces the recipients of a mail that is already queued. A private note
is the easiest way to hit it: private notes are outgoing messages, and
on an Email inbox `Messages::MessageBuilder#process_emails` stores them
with empty `to_emails` / `cc_emails` / `bcc_emails`.

Both lookups now go through the existing `current_message` helper
(`@message || @conversation.messages.outgoing.last`), which the `from`
and `reply_to` builders already use. `email_reply` sets `@message`, so
it resolves the recipients of the message being delivered;
`reply_with_summary` and `reply_without_summary` leave `@message` nil
and keep their current behaviour.

Added a spec covering the private-note case in
`spec/mailers/conversation_reply_mailer_spec.rb`.

## Note

I could not run the Ruby test suite locally — there is no Ruby toolchain
in the environment I worked in, so the new spec has not been executed on
my side. The change and the spec were verified by reading the code paths
(`Message#send_reply`, `Email::SendOnEmailService`,
`Messages::MessageBuilder#process_emails`,
`ConversationReplyMailerHelper#prepare_mail`). Deferring to CI for the
actual run.
2026-07-30 17:53:11 -07:00

226 lines
7.1 KiB
Ruby

class ConversationReplyMailer < ApplicationMailer
# We needs to expose large attachments to the view as links
# Small attachments are linked as mail attachments directly
attr_reader :large_attachments
include ConversationReplyMailerHelper
include ReferencesHeaderBuilder
include EmailAddressParseable
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
layout :choose_layout
def reply_with_summary(conversation, last_queued_id)
return unless smtp_config_set_or_development?
init_conversation_attributes(conversation)
return if conversation_already_viewed?
recap_messages = @conversation.messages.chat.where('id < ?', last_queued_id).last(10)
new_messages = @conversation.messages.chat.where('id >= ?', last_queued_id)
@messages = recap_messages + new_messages
@messages = @messages.select(&:email_reply_summarizable?)
prepare_mail(true)
end
def reply_without_summary(conversation, last_queued_id)
return unless smtp_config_set_or_development?
init_conversation_attributes(conversation)
return if conversation_already_viewed?
@messages = @conversation.messages.chat.where(message_type: [:outgoing, :template]).where('id >= ?', last_queued_id)
@messages = @messages.reject { |m| m.template? && !m.input_csat? }
return false if @messages.count.zero?
prepare_mail(false)
end
def email_reply(message)
init_conversation_attributes(message.conversation)
return unless smtp_config_set_or_development? || email_smtp_enabled? || (email_imap_enabled? && email_oauth_enabled?)
@message = message
prepare_mail(true)
end
def conversation_transcript(conversation, to_email)
return unless smtp_config_set_or_development?
init_conversation_attributes(conversation)
@messages = @conversation.messages.chat.select(&:conversation_transcriptable?)
Rails.logger.info("Email sent from #{from_email_with_name} \
to #{to_email} with subject #{@conversation.display_id} \
#{I18n.t('conversations.reply.transcript_subject')} ")
mail({
to: to_email,
from: from_email_with_name,
subject: "[##{@conversation.display_id}] #{I18n.t('conversations.reply.transcript_subject')}"
})
end
private
def init_conversation_attributes(conversation)
@conversation = conversation
@account = @conversation.account
@contact = @conversation.contact
@agent = @conversation.assignee
@inbox = @conversation.inbox
@channel = @inbox.channel
Current.account = @account
Current.inbox = @inbox
end
def should_use_conversation_email_address?
@inbox.inbox_type == 'Email' || inbound_email_enabled?
end
def conversation_already_viewed?
# whether contact already saw the message on widget
return unless @conversation.contact_last_seen_at
return unless last_outgoing_message&.created_at
@conversation.contact_last_seen_at > last_outgoing_message&.created_at
end
def last_outgoing_message
@conversation.messages.chat.where.not(message_type: :incoming)&.last
end
def sender_name(sender_email)
if @inbox.friendly?
I18n.t('conversations.reply.email.header.friendly_name', sender_name: custom_sender_name, business_name: business_name,
from_email: sender_email)
else
I18n.t('conversations.reply.email.header.professional_name', business_name: business_name, from_email: sender_email)
end
end
def current_message
@message || @conversation.messages.outgoing.last
end
def custom_sender_name
current_message&.sender&.available_name || @agent&.available_name || I18n.t('conversations.reply.email.header.notifications')
end
def business_name
@inbox.sanitized_business_name
end
def from_email
should_use_conversation_email_address? ? parse_email(@account.support_email) : parse_email(inbox_from_email_address)
end
def mail_subject
subject = @conversation.additional_attributes['mail_subject']
return "[##{@conversation.display_id}] #{I18n.t('conversations.reply.email_subject')}" if subject.nil?
chat_count = @conversation.messages.chat.count
if chat_count > 1
"Re: #{subject}"
else
subject
end
end
def reply_email
if should_use_conversation_email_address?
sender_name("reply+#{@conversation.uuid}@#{@account.inbound_email_domain}")
else
@inbox.email_address || @agent&.email
end
end
def from_email_with_name
sender_name(from_email)
end
def channel_email_with_name
sender_name(@channel.email)
end
def inbox_from_email_address
return @inbox.email_address if @inbox.email_address
@account.support_email
end
def custom_message_id
last_message = @message || @messages&.last
"<conversation/#{@conversation.uuid}/messages/#{last_message&.id}@#{channel_email_domain}>"
end
def in_reply_to_email
conversation_reply_email_id || "<account/#{@account.id}/conversation/#{@conversation.uuid}@#{channel_email_domain}>"
end
def conversation_reply_email_id
# Find the last incoming message's message_id to reply to
content_attributes = @conversation.messages.incoming.last&.content_attributes
if content_attributes && content_attributes['email'] && content_attributes['email']['message_id']
return "<#{content_attributes['email']['message_id']}>"
end
nil
end
def references_header
build_references_header(@conversation, in_reply_to_email)
end
def cc_bcc_emails
content_attributes = current_message&.content_attributes
return [] unless content_attributes
return [] unless content_attributes[:cc_emails] || content_attributes[:bcc_emails]
[content_attributes[:cc_emails], content_attributes[:bcc_emails]]
end
def to_emails_from_content_attributes
content_attributes = current_message&.content_attributes
return [] unless content_attributes
return [] unless content_attributes[:to_emails]
content_attributes[:to_emails]
end
def to_emails
# if there is no to_emails from content_attributes, send it to @contact&.email
to_emails_from_content_attributes.presence || [@contact&.email]
end
def inbound_email_enabled?
@inbound_email_enabled ||= @account.feature_enabled?('inbound_emails') && @account.inbound_email_domain
.present? && @account.support_email.present?
end
def choose_layout
return 'mailer/base' if branded_email_layout_action?
return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
'mailer/base'
end
def branded_email_layout_action?
return false unless action_name.in?(%w[email_reply reply_without_summary])
return @inbox.branded_email_layout_available? if @inbox&.email?
@account&.feature_enabled?(:branded_email_templates) && EmailTemplate.account_branded_layout_template_for(@account).present?
end
def liquid_droppables
super.merge({
agent: current_message&.sender || @agent,
contact: @contact,
message: @message || @messages&.last
})
end
end