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.
226 lines
7.1 KiB
Ruby
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
|