fix(email): resolve cid: inline images in multipart/related messages (#89)
Emails with multipart/related structure reference embedded images via cid: URIs. The WebView's CSP only allows data:/blob: sources, so those images were never shown. injectInlineImages() now replaces each cid: reference with a data: URI using the decoded bytes from the MIME tree, both for double-quoted and single-quoted src attributes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
69e358204d
commit
653ef92430
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
'<html><body><img src="cid:$cidHost"></body></html>',
|
||||
'--$innerBoundary--',
|
||||
'',
|
||||
'--$boundary',
|
||||
'Content-Type: image/png',
|
||||
'Content-Disposition: inline',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'Content-ID: <$cidHost>',
|
||||
'',
|
||||
_kPixelPng,
|
||||
'--$boundary--',
|
||||
].join('\r\n');
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('injectInlineImages', () {
|
||||
test('replaces cid: reference with data: URI', () {
|
||||
final msg = imap.MimeMessage.parseFromText(_buildRelatedMime());
|
||||
const html = '<img src="cid:test@example.com">';
|
||||
|
||||
final result = injectInlineImages(html, msg);
|
||||
|
||||
expect(result, contains('src="data:image/png;base64,'));
|
||||
expect(result, isNot(contains('cid:')));
|
||||
});
|
||||
|
||||
test('leaves HTML unchanged when there are no inline parts', () {
|
||||
// A plain text-only message.
|
||||
const plainMime = 'MIME-Version: 1.0\r\n'
|
||||
'Content-Type: text/plain\r\n'
|
||||
'\r\n'
|
||||
'Hello';
|
||||
final msg = imap.MimeMessage.parseFromText(plainMime);
|
||||
const html = '<img src="cid:foo@bar">';
|
||||
|
||||
expect(injectInlineImages(html, msg), html);
|
||||
});
|
||||
|
||||
test('handles single-quoted src attribute', () {
|
||||
final msg = imap.MimeMessage.parseFromText(_buildRelatedMime());
|
||||
const html = "<img src='cid:test@example.com'>";
|
||||
|
||||
final result = injectInlineImages(html, msg);
|
||||
|
||||
expect(result, contains("src='data:image/png;base64,"));
|
||||
expect(result, isNot(contains('cid:')));
|
||||
});
|
||||
|
||||
test('embedded data is the same base64 as the MIME part', () {
|
||||
final msg = imap.MimeMessage.parseFromText(_buildRelatedMime());
|
||||
const html = '<img src="cid:test@example.com">';
|
||||
|
||||
final result = injectInlineImages(html, msg);
|
||||
|
||||
// Extract base64 payload from the data URI.
|
||||
final match =
|
||||
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
|
||||
expect(match, isNotNull);
|
||||
final decoded = base64.decode(match!.group(1)!);
|
||||
expect(decoded.length, greaterThan(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user