diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb index 2b5fa1a26..f7a44ccca 100644 --- a/app/mailboxes/mailbox_helper.rb +++ b/app/mailboxes/mailbox_helper.rb @@ -1,4 +1,6 @@ module MailboxHelper + include MailboxInlineAttachmentHelper + private def create_message @@ -24,6 +26,9 @@ module MailboxHelper def add_attachments_to_message return if @message.blank? + # Load email content once for all attachment processing + load_email_content + # 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) @@ -38,7 +43,7 @@ module MailboxHelper # 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/') + inline_attachment?(attachment) end regular_attachments = attachments - inline_attachments @@ -59,11 +64,6 @@ module MailboxHelper 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 @@ -81,12 +81,6 @@ module MailboxHelper 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 = "\"#{attachment_name}\"" diff --git a/app/mailboxes/mailbox_inline_attachment_helper.rb b/app/mailboxes/mailbox_inline_attachment_helper.rb new file mode 100644 index 000000000..2308e2d4e --- /dev/null +++ b/app/mailboxes/mailbox_inline_attachment_helper.rb @@ -0,0 +1,45 @@ +module MailboxInlineAttachmentHelper + private + + def load_email_content + @html_content = processed_mail.serialized_data[:html_content][:full] + @text_content = processed_mail.serialized_data[:text_content][:reply] + end + + def inline_attachment?(attachment) + # Only process images as potential inline attachments + return false unless mail_content.present? && attachment[:original].content_type.to_s.start_with?('image/') + + # Check if attachment is explicitly marked as inline + return true if attachment[:original].inline? + + # For Outlook compatibility: if not marked as inline but has CID and is referenced in body + cid = attachment[:original].cid + cid.present? && body_references_cid?(cid) + end + + def body_references_cid?(cid) + # Check if CID is referenced in HTML content + return false if @html_content.blank? + + cid_urls_for(cid).any? { |cid_url| @html_content.include?(cid_url) } + end + + def upload_inline_image(mail_attachment) + content_id = mail_attachment[:original].cid + image_url = inline_image_url(mail_attachment[:blob]).to_s + + cid_urls_for(content_id).each do |cid_url| + @html_content = @html_content.gsub(cid_url, image_url) + end + end + + def cid_urls_for(cid) + # RFC 2392 cid URLs can contain URL-encoded Content-ID values. + # Check both raw and encoded variants so clients using either form render inline images. + encoded_cid = ERB::Util.url_encode(cid) + lowercase_encoded_cid = encoded_cid.gsub(/%[0-9A-F]{2}/, &:downcase) + + ["cid:#{cid}", "cid:#{encoded_cid}", "cid:#{lowercase_encoded_cid}"].uniq + end +end diff --git a/spec/fixtures/files/cid_inline_images_without_disposition.eml b/spec/fixtures/files/cid_inline_images_without_disposition.eml new file mode 100644 index 000000000..08bc4846b --- /dev/null +++ b/spec/fixtures/files/cid_inline_images_without_disposition.eml @@ -0,0 +1,83 @@ +Delivered-To: test.user@example.com +Received: by 192.0.2.1 with SMTP id smtp12345; + Mon, 14 Jul 2025 15:23:57 -0700 (PDT) +X-Google-Smtp-Source: TEST_SOURCE +X-Received: by 198.51.100.2 with SMTP id smtp67890; + Mon, 14 Jul 2025 15:23:57 -0700 (PDT) +ARC-Seal: i=1; a=rsa-sha256; t=1752531837; cv=none; + d=example.com; s=test; + b=TEST_SIGNATURE== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=test; + h=content-language:subject:to:from; + bh=TESTHASH==; + b=TEST_SIGNATURE== +ARC-Authentication-Results: i=1; mx.example.com; + dkim=pass header.i=@example.com header.s=test header.b="TESTKEY"; + spf=pass smtp.mailfrom=sender@example.com +Return-Path: +Received: from smtp.example.com (smtp.example.com. [203.0.113.10]) + by mx.example.com with ESMTPS id smtp7890 + for ; + Mon, 14 Jul 2025 15:23:55 -0700 (PDT) +Received-SPF: pass (example.com: domain of sender@example.com designates 203.0.113.10 as permitted sender) +Authentication-Results: mx.example.com; + dkim=pass header.i=@example.com header.s=test header.b="TESTKEY"; + spf=pass smtp.mailfrom=sender@example.com +Received: from TEST-PC (unknown [192.0.2.100]) + (Authenticated sender: sender@example.com) + by smtp.example.com (Postfix) with ESMTPA id ABCD123456 + for ; Mon, 14 Jul 2025 19:23:43 -0300 (BRT) +DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; d=example.com; + s=test; t=1752531824; bh=TESTHASH=; + h=From:To:Subject:Date:From; + b=TEST_SIGNATURE== +From: +To: +Subject: Test inline image without Content-Disposition +Date: Mon, 14 Jul 2025 19:23:29 -0300 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/related; + boundary="----=_NextPart_000_0001" +X-Mailer: Microsoft Outlook 16.0 +Thread-Index: TESTINDEX== +Content-Language: en-us + +This is a multipart message in MIME format. + +------=_NextPart_000_0001 +Content-Type: multipart/alternative; + boundary="----=_NextPart_001_0002" + +------=_NextPart_001_0002 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: 7bit + +This is a plain text version of the message. + +------=_NextPart_001_0002 +Content-Type: text/html; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + + + +

This is an HTML message with an inline image.

+ + + + +------=_NextPart_001_0002-- + +------=_NextPart_000_0001 +Content-Type: image/jpeg; + filename="image001.jpg" +Content-Transfer-Encoding: base64 +Content-ID: + +/9j/4AAQSkZJRgABAQEASABIAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEB +AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAAL +CABkAGQBAREA/8QA... + +------=_NextPart_000_0001-- diff --git a/spec/mailboxes/mailbox_helper_spec.rb b/spec/mailboxes/mailbox_helper_spec.rb index 8352a46a1..613040cb3 100644 --- a/spec/mailboxes/mailbox_helper_spec.rb +++ b/spec/mailboxes/mailbox_helper_spec.rb @@ -77,4 +77,60 @@ RSpec.describe MailboxHelper do expect(text_content).to include(Rails.application.routes.url_helpers.url_for(mail_attachment[:blob])) end end + + describe '#body_references_cid?' do + let(:helper_instance) { mailbox_helper_obj.new(conversation, processed_mail) } + + it 'detects percent-encoded CID references in HTML content' do + helper_instance.instance_variable_set(:@html_content, '') + + expect(helper_instance.send(:body_references_cid?, 'image001.jpg@test')).to be true + end + end + + describe '#upload_inline_image' do + let(:mail_attachment) do + { + original: OpenStruct.new(cid: 'image001.jpg@test'), + blob: get_blob_for('spec/assets/avatar.png', 'image/png') + } + end + let(:helper_instance) { mailbox_helper_obj.new(conversation, processed_mail) } + + it 'replaces percent-encoded CID references in HTML content' do + allow(Rails.application.routes.url_helpers).to receive(:url_for).and_return('/fake-image-url') + helper_instance.instance_variable_set(:@html_content, '') + + helper_instance.send(:upload_inline_image, mail_attachment) + + html_content = helper_instance.instance_variable_get(:@html_content) + expect(html_content).to include('/fake-image-url"') + expect(html_content).not_to include('cid:') + end + end + + describe '#add_attachments_to_message' do + let(:mail) { create_inbound_email_from_fixture('cid_inline_images_without_disposition.eml').mail } + let(:processed_mail) { MailPresenter.new(mail) } + let(:conversation) { create(:conversation) } + let(:helper_instance) { mailbox_helper_obj.new(conversation, processed_mail) } + + before do + helper_instance.send(:create_message) + end + + it 'detects inline image attachment by cid reference when Content-Disposition is missing' do + allow(Rails.application.routes.url_helpers).to receive(:url_for).and_return('/fake-image-url') + helper_instance.send(:add_attachments_to_message) + + message = conversation.messages[0] + + expect(message.attachments.count).to eq(0) + + html_content = message.content_attributes[:email][:html_content][:full] + + expect(html_content).to include('/fake-image-url"') + expect(html_content).not_to include('cid:') + end + end end