diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb index 9fc7a435d..07b689a2f 100644 --- a/app/mailboxes/application_mailbox.rb +++ b/app/mailboxes/application_mailbox.rb @@ -5,6 +5,7 @@ class ApplicationMailbox < ActionMailbox::Base # Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)} + CONVERSATION_FALLBACK_ID_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@} # routes as a reply to existing conversations routing( @@ -24,14 +25,27 @@ class ApplicationMailbox < ActionMailbox::Base # def in_reply_to_mail?(inbound_mail) in_reply_to = inbound_mail.mail.in_reply_to + references = inbound_mail.mail.references - in_reply_to.present? && ( + # Check in_reply_to first + return true if in_reply_to.present? && ( in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to) ) + + # Fallback to checking references header + references.present? && references_match?(references) end def in_reply_to_matches?(in_reply_to) - Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) } + Array.wrap(in_reply_to).any? { |id| id.match?(CONVERSATION_MESSAGE_ID_PATTERN) || id.match?(CONVERSATION_FALLBACK_ID_PATTERN) } + end + + def references_match?(references) + Array.wrap(references).any? do |reference| + reference.match?(CONVERSATION_MESSAGE_ID_PATTERN) || + reference.match?(CONVERSATION_FALLBACK_ID_PATTERN) || + Message.exists?(source_id: reference) + end end # checks if follow this pattern send it to reply_mailbox diff --git a/app/mailboxes/reply_mailbox.rb b/app/mailboxes/reply_mailbox.rb index 0b1efd09a..7acd441ba 100644 --- a/app/mailboxes/reply_mailbox.rb +++ b/app/mailboxes/reply_mailbox.rb @@ -23,6 +23,8 @@ class ReplyMailbox < ApplicationMailbox find_conversation_with_uuid elsif mail.in_reply_to.present? find_conversation_with_in_reply_to + elsif mail.references.present? + find_conversation_with_references end end @@ -79,6 +81,55 @@ class ReplyMailbox < ApplicationMailbox find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank? end + # Find conversation from References header as fallback + # Extract conversation UUID from any reference that matches our patterns + def find_conversation_with_references + references_addresses = mail.references + references_addresses = [references_addresses] if references_addresses.is_a?(String) + + references_addresses.each do |reference| + conversation = find_conversation_from_reference(reference) + next unless conversation && conversation_belongs_to_channel?(conversation) + + @conversation = conversation + @conversation_uuid = conversation.uuid + break + end + end + + def find_conversation_from_reference(reference) + # Try message-specific pattern: conversation/{uuid}/messages/{id}@domain + message_match = reference.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN) + if message_match + uuid = message_match.captures[0] + conversation = Conversation.find_by(uuid: uuid) + return conversation if conversation.present? + end + + # Try conversation fallback pattern: account/{id}/conversation/{uuid}@domain + fallback_match = reference.match(::ApplicationMailbox::CONVERSATION_FALLBACK_ID_PATTERN) + if fallback_match + uuid = fallback_match.captures[1] + conversation = Conversation.find_by(uuid: uuid) + return conversation if conversation.present? + end + + # Try finding by message source_id + message = Message.find_by(source_id: reference) + message&.conversation + end + + def conversation_belongs_to_channel?(conversation) + return true unless conversation + + # Get the channel from the email's To/CC addresses + channel = EmailChannelFinder.new(mail).perform + return false unless channel + + # Check if the conversation's inbox matches the channel + conversation.inbox.channel_id == channel.id + end + def validate_resource(resource) Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil? diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb index 167777218..b8b906494 100644 --- a/spec/mailboxes/reply_mailbox_spec.rb +++ b/spec/mailboxes/reply_mailbox_spec.rb @@ -248,5 +248,100 @@ RSpec.describe ReplyMailbox do ) end end + + context 'with references header' do + let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') } + let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) } + let(:conversation_1) do + create( + :conversation, + assignee: agent, + inbox: email_channel.inbox, + account: account, + additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" } + ) + end + + before do + conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489') + end + + context 'with message-specific pattern in references' do + before do + reply_mail_with_references.mail['References'] = '' + end + + it 'finds conversation from references header with message pattern' do + described_class.receive reply_mail_with_references + expect(conversation_1.messages.last.content).to include("Let's talk about these images:") + end + end + + context 'with conversation fallback pattern in references' do + before do + reply_mail_with_references.mail['References'] = "" + end + + it 'finds conversation from references header with fallback pattern' do + described_class.receive reply_mail_with_references + expect(conversation_1.messages.last.content).to include("Let's talk about these images:") + end + end + + context 'with multiple references including conversation pattern' do + before do + reply_mail_with_references.mail['References'] = [ + '', + "", + '' + ].join("\r\n ") + end + + it 'finds conversation from any reference in the chain' do + described_class.receive reply_mail_with_references + expect(conversation_1.messages.last.content).to include("Let's talk about these images:") + end + end + + context 'with message source_id in references' do + before do + conversation_1.messages.create!( + source_id: 'original-message-id@test.com', + account_id: account.id, + message_type: 'outgoing', + inbox_id: email_channel.inbox.id, + content: 'Original message' + ) + reply_mail_with_references.mail['References'] = '' + end + + it 'finds conversation from message source_id in references' do + described_class.receive reply_mail_with_references + expect(conversation_1.messages.last.content).to include("Let's talk about these images:") + end + end + + context 'with conversation from different channel in references' do + let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) } + let(:other_conversation) do + create( + :conversation, + assignee: agent, + inbox: other_email_channel.inbox, + account: account + ) + end + + before do + other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + reply_mail_with_references.mail['References'] = '' + end + + it 'does not use conversation from different channel' do + described_class.receive reply_mail_with_references + expect(other_conversation.messages.count).to eq(0) + end + end + end end end