Files
chatwoot/app/mailboxes/mailbox_helper.rb
Pranav 24f49b9b5a fix: Process non-image inline attachments as regular attachments (#10998)
In Chatwoot, we rely on the Content-ID for inline attachments to replace
the link with the uploaded attachment URL. Our expectation was that only
images would be inline, while other attachments would not. However,
email clients like Apple Mail (sigh) allow users to send inline
attachments that are not images, and these attachments often lack a
Content-ID. This creates significant issues in rendering.
 
I investigated how other email clients handle this scenario. When
viewing the same email (sent from Apple Mail) in Gmail, only one image
appears—and it’s treated as an attachment, not inline. This happens
because both attachments are the same image, and Apple Mail only sends
one copy. See the screenshot below.

| Apple Mail | Gmail | 
| -- | -- | 
| <img width="646" alt="Screenshot 2025-02-27 at 8 20 17 PM"
src="https://github.com/user-attachments/assets/e0d1cd2d-e47c-4081-a53b-7a67106341b3"
/> | <img width="360" alt="Screenshot 2025-02-27 at 8 20 51 PM"
src="https://github.com/user-attachments/assets/b206e56e-8f86-43e9-867b-d895c36aff78"
/> |

A good fix for this would be to check if the Content-ID is missing and
then upload the file as a regular attachment. However, the Mail gem (for
some reason) automatically adds a default Content-ID to inline parts. I
need to dig into the source code to understand why this happens.

For now, I’ve implemented a check to treat non-image attachments as
regular attachments. Inline image attachments are already handled by
appending an image tag at the end if the content-id is not found in the
body. A sample conversation to test this behavior is
[here](https://app.chatwoot.com/app/accounts/1/conversations/46732).
2025-02-28 13:33:48 -08:00

130 lines
4.6 KiB
Ruby

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