From 0ccf7b51fab580d4dbfe8e3aa591513962aa028c Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Fri, 15 May 2026 09:27:12 +0200 Subject: [PATCH] fix: fetch original RFC822 from server in Show Raw Email (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of reconstructing the message from the local DB, fetch the original bytes live from IMAP (BODY.PEEK[]) or JMAP (Email/get blobId → downloadBlob) so the view shows the true unmodified message. Co-Authored-By: Claude Sonnet 4.6 --- lib/core/repositories/email_repository.dart | 4 ++ .../repositories/email_repository_impl.dart | 59 +++++++++++++++++++ lib/ui/screens/email_detail_screen.dart | 31 +++++----- .../account_sync_manager_test.dart | 3 + test/unit/account_sync_manager_test.dart | 2 + test/unit/reliability_runner_test.dart | 2 + test/widget/helpers.dart | 3 + 7 files changed, 87 insertions(+), 17 deletions(-) diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 0986474..cf69e7a 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -41,6 +41,10 @@ abstract class EmailRepository { /// return the cached path without a network round-trip. Future downloadAttachment(String emailId, EmailAttachment attachment); + /// Fetches the original RFC 2822 message from the server as a raw string. + /// Always performs a live network request — the raw message is not cached. + Future fetchRawRfc822(String emailId); + /// Returns emails in [mailboxPath] whose subject or body contain [query]. /// Results come from the server (IMAP SEARCH) and are not cached. Future> searchEmails( diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 260194e..c22c497 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2533,6 +2533,65 @@ class EmailRepositoryImpl implements EmailRepository { } } + @override + Future fetchRawRfc822(String emailId) async { + final emailRow = await (_db.select( + _db.emails, + )..where((t) => t.id.equals(emailId))) + .getSingle(); + final account = (await _accounts.getAccount(emailRow.accountId))!; + final password = await _accounts.getPassword(account.id); + + if (account.type == account_model.AccountType.jmap) { + final jmap = await JmapClient.connect( + httpClient: _httpClient, + jmapUrl: Uri.parse(account.jmapUrl!), + username: _effectiveUsername(account), + password: password, + ); + final jmapEmailId = emailId.contains(':') + ? emailId.substring(emailId.indexOf(':') + 1) + : emailId; + final responses = await jmap.call([ + [ + 'Email/get', + { + 'accountId': jmap.accountId, + 'ids': [jmapEmailId], + 'properties': ['id', 'blobId'], + }, + '0', + ], + ]); + final result = _responseArgs(responses, 0, 'Email/get'); + final emailData = + (result['list'] as List).first as Map; + final blobId = emailData['blobId'] as String; + final bytes = await jmap.downloadBlob( + blobId, + name: 'email.eml', + type: 'message/rfc822', + ); + return utf8.decode(bytes, allowMalformed: true); + } + + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); + try { + await client.selectMailboxByPath(emailRow.mailboxPath); + final fetch = await client.uidFetchMessage( + emailRow.uid, + 'BODY.PEEK[]', + ); + return fetch.messages.first.renderMessage(); + } finally { + await client.logout(); + } + } + @override Future> searchEmailsGlobal( String? accountId, diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 9a64a35..d7fbfc2 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -159,8 +159,8 @@ class _EmailDetailScreenState extends ConsumerState { onSelected: (value) { if (value == 'headers' && body != null) { _showHeaders(context, body); - } else if (value == 'rfc' && body != null) { - unawaited(_showRaw(context, header, body)); + } else if (value == 'rfc') { + unawaited(_showRaw(context, header)); } }, ), @@ -428,22 +428,19 @@ class _EmailDetailScreenState extends ConsumerState { } } - Future _showRaw( - BuildContext context, - Email? header, - EmailBody body, - ) async { - final buf = StringBuffer(); - for (final h in body.headers) { - buf.write('${h.name}: ${h.value}\n'); + Future _showRaw(BuildContext context, Email? header) async { + final String raw; + try { + raw = await ref + .read(emailRepositoryProvider) + .fetchRawRfc822(widget.emailId); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to fetch raw email: $e')), + ); + return; } - buf.write('\n'); - if (body.textBody != null && body.textBody!.isNotEmpty) { - buf.write(body.textBody); - } else if (body.htmlBody != null && body.htmlBody!.isNotEmpty) { - buf.write(await compute(htmlToPlain, body.htmlBody!)); - } - final raw = buf.toString(); if (!context.mounted) return; diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index d3de941..3822b45 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -227,6 +227,9 @@ class _FakeEmails implements EmailRepository { ) async => '/tmp/${attachment.filename}'; + @override + Future fetchRawRfc822(String emailId) async => ''; + @override Future> searchEmails(String a, String m, String q) async => []; diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index c364d1b..37e2cd5 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -88,6 +88,8 @@ class FakeEmailRepository implements EmailRepository { @override Future downloadAttachment(String id, EmailAttachment a) async => ''; @override + Future fetchRawRfc822(String emailId) async => ''; + @override Future> searchEmails(String a, String m, String q) async => []; @override Future> searchEmailsGlobal(String? a, String q) async => []; diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index ffb947d..f3dde40 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -113,6 +113,8 @@ class _CountingEmails implements EmailRepository { @override Future downloadAttachment(String id, EmailAttachment att) async => ''; @override + Future fetchRawRfc822(String emailId) async => ''; + @override Future> searchEmails(String a, String m, String q) async => []; @override Future> searchEmailsGlobal(String? a, String q) async => []; diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index cd88115..7f260d0 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -251,6 +251,9 @@ class FakeEmailRepository implements EmailRepository { ) async => '/tmp/${attachment.filename}'; + @override + Future fetchRawRfc822(String emailId) async => ''; + @override Future> searchEmails( String accountId,