From 8cdb00c0bdc79def59743145d1e2718dca92a226 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Fri, 15 May 2026 12:53:13 +0200 Subject: [PATCH] feat(email): show nested MIME structure in email detail screen (#88) Adds a MimePart tree model, parses it from the IMAP BODYSTRUCTURE when fetching the email body, caches it in a new mime_tree_json column (schema v28), and exposes a 'Show Mail Structure' overflow menu item that renders the indented tree (content-type, filename, size, encoding) in an AlertDialog alongside the existing headers dialog. Co-Authored-By: Claude Sonnet 4.6 --- lib/core/models/email.dart | 18 ++++ lib/data/db/database.dart | 7 +- .../repositories/email_repository_impl.dart | 45 ++++++++++ lib/ui/screens/email_detail_screen.dart | 88 +++++++++++++++++++ test/unit/migration_test.dart | 28 +++++- 5 files changed, 183 insertions(+), 3 deletions(-) diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index 27952e0..c61e868 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -232,12 +232,29 @@ class EmailHeader { } /// Full message body — fetched on demand, cached in the local DB. +class MimePart { + final String contentType; + final String? filename; + final int? size; + final String? encoding; + final List children; + + const MimePart({ + required this.contentType, + this.filename, + this.size, + this.encoding, + this.children = const [], + }); +} + class EmailBody { final String emailId; final String? textBody; final String? htmlBody; final List attachments; final List headers; + final MimePart? mimeTree; const EmailBody({ required this.emailId, @@ -245,6 +262,7 @@ class EmailBody { this.htmlBody, required this.attachments, this.headers = const [], + this.mimeTree, }); } diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index dc18862..88e866b 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -107,6 +107,8 @@ class EmailBodies extends Table { DateTimeColumn get cachedAt => dateTime().nullable()(); // Added in schema v20: raw or parsed headers TextColumn get headersJson => text().nullable()(); + // Added in schema v28: serialised MimePart tree (JSON) + TextColumn get mimeTreeJson => text().nullable()(); @override Set get primaryKey => {emailId}; @@ -277,7 +279,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 27; + int get schemaVersion => 28; Future _createEmailFts() async { await customStatement(''' @@ -503,6 +505,9 @@ class AppDatabase extends _$AppDatabase { if (from < 27) { await m.createTable(searchHistoryEntries); } + if (from < 28) { + await m.addColumn(emailBodies, emailBodies.mimeTreeJson); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index b463767..aa11ce5 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -262,6 +262,8 @@ class EmailRepositoryImpl implements EmailRepository { .toList(), ); + final mimeTreeJson = _buildMimeTreeJson(msg); + await _db.into(_db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: emailId, @@ -269,6 +271,7 @@ class EmailRepositoryImpl implements EmailRepository { htmlBody: Value(htmlBody), attachmentsJson: Value(attachmentsJson), headersJson: Value(headersJson), + mimeTreeJson: Value(mimeTreeJson), cachedAt: Value(DateTime.now()), ), ); @@ -278,6 +281,7 @@ class EmailRepositoryImpl implements EmailRepository { htmlBody: htmlBody, attachments: _parseAttachments(attachmentsJson), headers: _parseHeaders(headersJson), + mimeTree: _parseMimeTree(mimeTreeJson), ); } finally { await client.logout(); @@ -2882,6 +2886,27 @@ class EmailRepositoryImpl implements EmailRepository { htmlBody: row.htmlBody, attachments: _parseAttachments(row.attachmentsJson), headers: _parseHeaders(row.headersJson), + mimeTree: _parseMimeTree(row.mimeTreeJson), + ); + + model.MimePart? _parseMimeTree(String? jsonStr) { + if (jsonStr == null || jsonStr.isEmpty) return null; + try { + return _mimePartFromJson(jsonDecode(jsonStr) as Map); + } catch (_) { + return null; + } + } + + model.MimePart _mimePartFromJson(Map m) => model.MimePart( + contentType: m['contentType'] as String? ?? 'application/octet-stream', + filename: m['filename'] as String?, + size: m['size'] as int?, + encoding: m['encoding'] as String?, + children: ((m['children'] as List?) ?? []) + .cast>() + .map(_mimePartFromJson) + .toList(), ); List _parseHeaders(String? jsonStr) { @@ -2973,3 +2998,23 @@ class EmailRepositoryImpl implements EmailRepository { } } } + +/// Recursively converts an [imap.MimePart] into a JSON-serialisable map. +Map _mimePartToJson(imap.MimePart part) { + final ct = part.getHeaderContentType(); + final disposition = part.getHeaderContentDisposition(); + final rawEncoding = + part.getHeader('content-transfer-encoding')?.firstOrNull?.value; + final encoding = rawEncoding?.split(';').first.trim().toLowerCase(); + return { + 'contentType': ct?.mediaType.text ?? 'application/octet-stream', + 'filename': disposition?.filename ?? ct?.parameters['name'], + 'size': disposition?.size, + 'encoding': encoding, + 'children': (part.parts ?? []).map(_mimePartToJson).toList(), + }; +} + +/// Builds a JSON string representing the MIME tree of [msg]. +String _buildMimeTreeJson(imap.MimeMessage msg) => + jsonEncode(_mimePartToJson(msg)); diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index d7fbfc2..5d8c3a9 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -151,6 +151,10 @@ class _EmailDetailScreenState extends ConsumerState { value: 'headers', child: Text('Show Mail Headers'), ), + const PopupMenuItem( + value: 'structure', + child: Text('Show Mail Structure'), + ), const PopupMenuItem( value: 'rfc', child: Text('Show Raw Email'), @@ -159,6 +163,8 @@ class _EmailDetailScreenState extends ConsumerState { onSelected: (value) { if (value == 'headers' && body != null) { _showHeaders(context, body); + } else if (value == 'structure' && body != null) { + _showStructure(context, body); } else if (value == 'rfc') { unawaited(_showRaw(context, header)); } @@ -563,6 +569,88 @@ class _EmailDetailScreenState extends ConsumerState { ), ); } + + void _showStructure(BuildContext context, EmailBody body) { + final tree = body.mimeTree; + if (tree == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text( + 'Structure not available. Try re-syncing the email.', + ), + ), + ); + return; + } + + final rows = <_MimeRow>[]; + _flattenMimeTree(tree, 0, rows); + + unawaited( + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Mail Structure'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: rows.length, + itemBuilder: (ctx, i) { + final row = rows[i]; + return Container( + color: i.isEven + ? Theme.of(ctx).colorScheme.surfaceContainerHighest + : Theme.of(ctx).colorScheme.surface, + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Row( + children: [ + SizedBox(width: row.depth * 16.0), + Expanded( + child: Text( + row.label, + style: Theme.of(ctx).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ), + ); + } +} + +class _MimeRow { + const _MimeRow(this.depth, this.label); + final int depth; + final String label; +} + +void _flattenMimeTree(MimePart part, int depth, List<_MimeRow> out) { + final parts = [part.contentType]; + if (part.filename != null) parts.add('"${part.filename}"'); + if (part.size != null) parts.add(fmtSize(part.size!)); + if (part.encoding != null) parts.add(part.encoding!); + out.add(_MimeRow(depth, parts.join(' '))); + for (final child in part.children) { + _flattenMimeTree(child, depth + 1, out); + } } /// Parses a List-Unsubscribe header and returns the first usable URI. diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 60af206..6615e18 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 27); + expect(db.schemaVersion, 28); await db.close(); }); @@ -176,6 +176,13 @@ void main() { .customSelect('SELECT count(*) FROM search_history_entries') .get(); + // v28: mime_tree_json column on email_bodies. + await db + .customSelect( + 'SELECT mime_tree_json FROM email_bodies LIMIT 0', + ) + .get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); @@ -273,6 +280,16 @@ void main() { PRIMARY KEY (account_id, mailbox_path, id) ); '''); + rawDb.execute(''' + CREATE TABLE email_bodies ( + email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE, + text_body TEXT NULL, + html_body TEXT NULL, + attachments_json TEXT NOT NULL DEFAULT '[]', + cached_at INTEGER NULL, + headers_json TEXT NULL + ); + '''); rawDb.execute('PRAGMA user_version = 22;'); rawDb.close(); @@ -311,11 +328,18 @@ void main() { .customSelect('SELECT count(*) FROM search_history_entries') .get(); + // v28: mime_tree_json column on email_bodies. + await db + .customSelect( + 'SELECT mime_tree_json FROM email_bodies LIMIT 0', + ) + .get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); - test('fresh install creates all tables at schemaVersion 27', () async { + test('fresh install creates all tables at schemaVersion 28', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get();