Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
40
research/chatwoot/app/mailboxes/application_mailbox.rb
Normal file
40
research/chatwoot/app/mailboxes/application_mailbox.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class ApplicationMailbox < ActionMailbox::Base
|
||||
include MailboxHelper
|
||||
|
||||
# Last part is the regex for the UUID
|
||||
# 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
|
||||
|
||||
# Route all emails to verified channels to the unified reply mailbox
|
||||
# The ConversationFinder will determine if it's a reply or new conversation
|
||||
routing(
|
||||
lambda { |inbound_mail|
|
||||
valid_to_address?(inbound_mail) &&
|
||||
(reply_uuid_mail?(inbound_mail) || EmailChannelFinder.new(inbound_mail.mail).perform.present?)
|
||||
} => :reply
|
||||
)
|
||||
|
||||
# catchall
|
||||
routing(all: :default)
|
||||
|
||||
class << self
|
||||
# checks if follows this pattern: reply+<conversation-uuid>@<mailer-domain.com>
|
||||
def reply_uuid_mail?(inbound_mail)
|
||||
inbound_mail.mail.to&.any? do |email|
|
||||
conversation_uuid = email.split('@')[0]
|
||||
conversation_uuid.match?(REPLY_EMAIL_UUID_PATTERN)
|
||||
end
|
||||
end
|
||||
|
||||
# if mail.to returns a string, then it is a malformed `to` header
|
||||
# valid `to` header will be of type Mail::AddressContainer
|
||||
# validate if the to address is of type string
|
||||
def valid_to_address?(inbound_mail)
|
||||
to_address_class = inbound_mail.mail.to&.class
|
||||
return true if to_address_class == Mail::AddressContainer
|
||||
|
||||
Rails.logger.error "Email to address header is malformed `#{inbound_mail.mail.to}`"
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
3
research/chatwoot/app/mailboxes/default_mailbox.rb
Normal file
3
research/chatwoot/app/mailboxes/default_mailbox.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class DefaultMailbox < ApplicationMailbox
|
||||
def process; end
|
||||
end
|
||||
124
research/chatwoot/app/mailboxes/imap/imap_mailbox.rb
Normal file
124
research/chatwoot/app/mailboxes/imap/imap_mailbox.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
class Imap::ImapMailbox
|
||||
include MailboxHelper
|
||||
include IncomingEmailValidityHelper
|
||||
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
|
||||
|
||||
FALLBACK_CONVERSATION_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
|
||||
|
||||
def process(mail, channel)
|
||||
@inbound_mail = mail
|
||||
@channel = channel
|
||||
load_account
|
||||
load_inbox
|
||||
decorate_mail
|
||||
|
||||
Rails.logger.info("Processing Email from: #{@processed_mail.original_sender} : inbox #{@inbox.id} : message_id #{@processed_mail.message_id}")
|
||||
|
||||
# Skip processing email if it belongs to any of the edge cases
|
||||
return unless incoming_email_from_valid_email?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
find_or_create_contact
|
||||
find_or_create_conversation
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account
|
||||
@account = @channel.account
|
||||
end
|
||||
|
||||
def load_inbox
|
||||
@inbox = @channel.inbox
|
||||
end
|
||||
|
||||
def decorate_mail
|
||||
@processed_mail = MailPresenter.new(@inbound_mail, @account)
|
||||
end
|
||||
|
||||
def find_conversation_by_in_reply_to
|
||||
return if in_reply_to.blank?
|
||||
|
||||
message = @inbox.messages.find_by(source_id: in_reply_to)
|
||||
if message.nil?
|
||||
@inbox.conversations.find_by("additional_attributes->>'in_reply_to' = ?", in_reply_to)
|
||||
else
|
||||
@inbox.conversations.find(message.conversation_id)
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_by_reference_ids
|
||||
return if @inbound_mail.references.blank?
|
||||
|
||||
message = find_message_by_references
|
||||
if message.present?
|
||||
conversation = @inbox.conversations.find_by(id: message.conversation_id)
|
||||
return conversation if conversation.present?
|
||||
end
|
||||
|
||||
# FALLBACK_PATTERN use to find a conversation that is started by an agent (no incoming message yet)
|
||||
conversation_id = find_conversation_by_references
|
||||
@inbox.conversations.find_by(uuid: conversation_id) if conversation_id.present?
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
@processed_mail.in_reply_to
|
||||
end
|
||||
|
||||
def find_conversation_by_references
|
||||
references = Array.wrap(@inbound_mail.references)
|
||||
references.each do |message_id|
|
||||
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
|
||||
|
||||
return match[2] if match.present?
|
||||
end
|
||||
end
|
||||
|
||||
def find_message_by_references
|
||||
message_to_return = nil
|
||||
|
||||
references = Array.wrap(@inbound_mail.references)
|
||||
|
||||
references.each do |message_id|
|
||||
message = @inbox.messages.find_by(source_id: message_id)
|
||||
message_to_return = message if message.present?
|
||||
end
|
||||
message_to_return
|
||||
end
|
||||
|
||||
def find_or_create_conversation
|
||||
@conversation = find_conversation_by_in_reply_to || find_conversation_by_reference_ids || ::Conversation.create!(
|
||||
{
|
||||
account_id: @account.id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: {
|
||||
source: 'email',
|
||||
in_reply_to: in_reply_to,
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_contact
|
||||
@contact = @inbox.contacts.from_email(@processed_mail.original_sender)
|
||||
if @contact.present?
|
||||
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
|
||||
else
|
||||
create_contact
|
||||
end
|
||||
end
|
||||
|
||||
def identify_contact_name
|
||||
processed_mail.sender_name || processed_mail.from.first.split('@').first
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module IncomingEmailValidityHelper
|
||||
private
|
||||
|
||||
def incoming_email_from_valid_email?
|
||||
return false unless valid_external_email_for_active_account?
|
||||
|
||||
# Return if email doesn't have a valid sender
|
||||
# This can happen in cases like bounce emails for invalid contact email address
|
||||
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def valid_external_email_for_active_account?
|
||||
return false unless @account.active?
|
||||
return false if @processed_mail.notification_email_from_chatwoot?
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
129
research/chatwoot/app/mailboxes/mailbox_helper.rb
Normal file
129
research/chatwoot/app/mailboxes/mailbox_helper.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
module MailboxHelper
|
||||
private
|
||||
|
||||
def create_message
|
||||
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
|
||||
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
|
||||
|
||||
@message = @conversation.messages.create!(
|
||||
account_id: @conversation.account_id,
|
||||
sender: @conversation.contact,
|
||||
content: mail_content&.truncate(150_000),
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: 'incoming',
|
||||
content_type: 'incoming_email',
|
||||
source_id: processed_mail.message_id,
|
||||
content_attributes: {
|
||||
email: processed_mail.serialized_data,
|
||||
cc_email: processed_mail.cc,
|
||||
bcc_email: processed_mail.bcc
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def add_attachments_to_message
|
||||
return if @message.blank?
|
||||
|
||||
# ensure we don't add more than the permitted number of attachments
|
||||
all_attachments = processed_mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS)
|
||||
grouped_attachments = group_attachments(all_attachments)
|
||||
|
||||
process_inline_attachments(grouped_attachments[:inline]) if grouped_attachments[:inline].present?
|
||||
process_regular_attachments(grouped_attachments[:regular]) if grouped_attachments[:regular].present?
|
||||
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def group_attachments(attachments)
|
||||
# If the email lacks a text body or if inline attachments aren't images,
|
||||
# treat them as standard attachments for processing.
|
||||
inline_attachments = attachments.select do |attachment|
|
||||
mail_content.present? && attachment[:original].inline? && attachment[:original].content_type.to_s.start_with?('image/')
|
||||
end
|
||||
|
||||
regular_attachments = attachments - inline_attachments
|
||||
{ inline: inline_attachments, regular: regular_attachments }
|
||||
end
|
||||
|
||||
def process_regular_attachments(attachments)
|
||||
Rails.logger.info "[MailboxHelper] Processing regular attachments for message with ID: #{processed_mail.message_id}"
|
||||
attachments.each do |mail_attachment|
|
||||
attachment = @message.attachments.new(
|
||||
account_id: @conversation.account_id,
|
||||
file_type: 'file'
|
||||
)
|
||||
attachment.file.attach(mail_attachment[:blob])
|
||||
end
|
||||
end
|
||||
|
||||
def process_inline_attachments(attachments)
|
||||
Rails.logger.info "[MailboxHelper] Processing inline attachments for message with ID: #{processed_mail.message_id}"
|
||||
|
||||
# create an instance variable here, the `embed_inline_image_source`
|
||||
# updates them directly. And then the value is eventaully used to update the message content
|
||||
@html_content = processed_mail.serialized_data[:html_content][:full]
|
||||
@text_content = processed_mail.serialized_data[:text_content][:reply]
|
||||
|
||||
attachments.each do |mail_attachment|
|
||||
embed_inline_image_source(mail_attachment)
|
||||
end
|
||||
|
||||
# update the message content with the updated html and text content
|
||||
@message.content_attributes[:email][:html_content][:full] = @html_content
|
||||
@message.content_attributes[:email][:text_content][:full] = @text_content
|
||||
end
|
||||
|
||||
def embed_inline_image_source(mail_attachment)
|
||||
if @html_content.present?
|
||||
upload_inline_image(mail_attachment)
|
||||
elsif @text_content.present?
|
||||
embed_plain_text_email_with_inline_image(mail_attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def upload_inline_image(mail_attachment)
|
||||
content_id = mail_attachment[:original].cid
|
||||
|
||||
@html_content = @html_content.gsub("cid:#{content_id}", inline_image_url(mail_attachment[:blob]).to_s)
|
||||
end
|
||||
|
||||
def embed_plain_text_email_with_inline_image(mail_attachment)
|
||||
attachment_name = mail_attachment[:original].filename
|
||||
img_tag = "<img src=\"#{inline_image_url(mail_attachment[:blob])}\" alt=\"#{attachment_name}\">"
|
||||
|
||||
tag_to_replace = "[image: #{attachment_name}]"
|
||||
|
||||
if @text_content.include?(tag_to_replace)
|
||||
@text_content = @text_content.gsub(tag_to_replace, img_tag)
|
||||
else
|
||||
@text_content += "\n\n#{img_tag}"
|
||||
end
|
||||
end
|
||||
|
||||
def inline_image_url(blob)
|
||||
Rails.application.routes.url_helpers.url_for(blob)
|
||||
end
|
||||
|
||||
def create_contact
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: processed_mail.original_sender,
|
||||
inbox: @inbox,
|
||||
contact_attributes: {
|
||||
name: identify_contact_name,
|
||||
email: processed_mail.original_sender,
|
||||
additional_attributes: { source_id: "email:#{processed_mail.message_id}" }
|
||||
}
|
||||
).perform
|
||||
|
||||
@contact = @contact_inbox.contact
|
||||
Rails.logger.info "[MailboxHelper] Contact created with ID: #{@contact.id} for inbox with ID: #{@inbox.id}"
|
||||
end
|
||||
|
||||
def mail_content
|
||||
if processed_mail.text_content.present?
|
||||
processed_mail.text_content[:reply]
|
||||
elsif processed_mail.html_content.present?
|
||||
processed_mail.html_content[:reply]
|
||||
end
|
||||
end
|
||||
end
|
||||
41
research/chatwoot/app/mailboxes/reply_mailbox.rb
Normal file
41
research/chatwoot/app/mailboxes/reply_mailbox.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class ReplyMailbox < ApplicationMailbox
|
||||
attr_accessor :conversation, :processed_mail
|
||||
|
||||
before_processing :find_conversation
|
||||
|
||||
def process
|
||||
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
|
||||
return unless @conversation
|
||||
|
||||
# Wrap everything in a transaction to ensure atomicity
|
||||
# This prevents orphan conversations if message/attachment creation fails
|
||||
# and ensures idempotency on job retry (conversation won't be duplicated)
|
||||
ActiveRecord::Base.transaction do
|
||||
persist_conversation_if_needed
|
||||
decorate_mail
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_conversation
|
||||
@conversation = Mailbox::ConversationFinder.new(mail).find
|
||||
# Log when email is rejected
|
||||
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
|
||||
end
|
||||
|
||||
def persist_conversation_if_needed
|
||||
# Save the conversation if it's a new record (from NewConversationStrategy)
|
||||
# We persist here instead of in the strategy to maintain transaction integrity
|
||||
return unless @conversation.new_record?
|
||||
|
||||
@conversation.save!
|
||||
Rails.logger.info "Created new conversation #{@conversation.id} for email #{mail.message_id}"
|
||||
end
|
||||
|
||||
def decorate_mail
|
||||
@processed_mail = MailPresenter.new(mail, @conversation.account)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user