diff --git a/lib/core/utils/cid_utils.dart b/lib/core/utils/cid_utils.dart new file mode 100644 index 0000000..1a761e9 --- /dev/null +++ b/lib/core/utils/cid_utils.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart' as imap; + +/// Replaces `src="cid:..."` references in [html] with inline `data:` URIs +/// by looking up each Content-ID in the MIME tree of [msg]. +/// +/// Emails with `multipart/related` often embed images this way. Without +/// substitution the WebView shows broken image icons even after the full +/// message has been downloaded. +String injectInlineImages(String html, imap.MimeMessage msg) { + final inlineParts = msg.findContentInfo( + disposition: imap.ContentDisposition.inline, + ); + if (inlineParts.isEmpty) return html; + + var result = html; + for (final info in inlineParts) { + final cid = info.cid; + if (cid == null || cid.isEmpty) continue; + final bareCid = cid.startsWith('<') && cid.endsWith('>') + ? cid.substring(1, cid.length - 1) + : cid; + + final part = msg.getPart(info.fetchId); + if (part == null) continue; + final bytes = part.decodeContentBinary(); + if (bytes == null || bytes.isEmpty) continue; + + final contentType = + info.contentType?.mediaType.text ?? 'application/octet-stream'; + final dataUri = 'data:$contentType;base64,${base64.encode(bytes)}'; + + result = result + .replaceAll('src="cid:$bareCid"', 'src="$dataUri"') + .replaceAll("src='cid:$bareCid'", "src='$dataUri'") + .replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"') + .replaceAll( + "src='cid:${bareCid.toLowerCase()}'", + "src='$dataUri'", + ); + } + return result; +} diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index ba015c1..b463767 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -13,6 +13,7 @@ import 'package:sharedinbox/core/models/account.dart' as account_model; import 'package:sharedinbox/core/models/email.dart' as model; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; +import 'package:sharedinbox/core/utils/cid_utils.dart'; import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart'; @@ -235,7 +236,9 @@ class EmailRepositoryImpl implements EmailRepository { final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); final msg = fetch.messages.first; final textBody = msg.decodeTextPlainPart(); - final htmlBody = msg.decodeTextHtmlPart(); + final rawHtml = msg.decodeTextHtmlPart(); + final htmlBody = + rawHtml == null ? null : injectInlineImages(rawHtml, msg); final contentInfos = msg.findContentInfo(); final attachmentsJson = jsonEncode( diff --git a/test/unit/cid_utils_test.dart b/test/unit/cid_utils_test.dart new file mode 100644 index 0000000..55d236b --- /dev/null +++ b/test/unit/cid_utils_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:enough_mail/enough_mail.dart' as imap; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/utils/cid_utils.dart'; + +// A minimal multipart/related email with one embedded PNG referenced via cid:. +// +// The image data is the 1×1 red pixel PNG (67 bytes) from RFC-tests tradition. +const _kPixelPng = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=='; + +// Build a synthetic RFC 2822 multipart/related message. +String _buildRelatedMime({String cidHost = 'test@example.com'}) { + const boundary = '----=_Part_TEST_BOUNDARY'; + const innerBoundary = '----=_Part_INNER_BOUNDARY'; + return [ + 'MIME-Version: 1.0', + 'Content-Type: multipart/related;', + '\ttype="multipart/alternative";', + '\tboundary="$boundary"', + '', + '--$boundary', + 'Content-Type: multipart/alternative;', + '\tboundary="$innerBoundary"', + '', + '--$innerBoundary', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'See the image below.', + '--$innerBoundary', + 'Content-Type: text/html; charset=UTF-8', + '', + '