Compare commits

...
3 changed files with 207 additions and 4 deletions
+16 -2
View File
@@ -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
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
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
+53 -2
View File
@@ -21,8 +21,10 @@ class ReplyMailbox < ApplicationMailbox
def find_relative_conversation
if @conversation_uuid
find_conversation_with_uuid
elsif mail.in_reply_to.present?
find_conversation_with_in_reply_to
elsif mail.in_reply_to.present? || mail.references.present?
find_conversation_with_in_reply_to if mail.in_reply_to.present?
# If still no conversation found and references exist, try references as fallback
find_conversation_with_references if @conversation.blank? && mail.references.present?
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?
+138
View File
@@ -248,5 +248,143 @@ 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'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
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'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
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'] = [
'<some-random-message-id@gmail.com>',
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
'<another-random-message-id@outlook.com>'
].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'] = '<original-message-id@test.com>'
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'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
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
context 'when in_reply_to fails but references has valid conversation' do
before do
reply_mail_with_references.mail['In-Reply-To'] = '<non-existent-message-id@test.com>'
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
end
it 'falls back to references header when in_reply_to lookup fails' 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 'when in_reply_to fails and references has message pattern' do
before do
reply_mail_with_references.mail['In-Reply-To'] = '<random-invalid-id@gmail.com>'
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/789@test.com>'
end
it 'falls back to references with message pattern when in_reply_to lookup fails' 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 'when in_reply_to fails and references has message source_id' do
before do
conversation_1.messages.create!(
source_id: 'valid-message-id@test.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Previous message'
)
reply_mail_with_references.mail['In-Reply-To'] = '<wrong-message-id@test.com>'
reply_mail_with_references.mail['References'] = '<valid-message-id@test.com>'
end
it 'falls back to references with source_id when in_reply_to lookup fails' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
end
end
end