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 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-15 12:53:13 +02:00
co-authored by Claude Sonnet 4.6
parent 653ef92430
commit 8cdb00c0bd
5 changed files with 183 additions and 3 deletions
+6 -1
View File
@@ -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<Column> 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<void> _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);
}
},
);
}
@@ -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<String, dynamic>);
} catch (_) {
return null;
}
}
model.MimePart _mimePartFromJson(Map<String, dynamic> 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<dynamic>?) ?? [])
.cast<Map<String, dynamic>>()
.map(_mimePartFromJson)
.toList(),
);
List<model.EmailHeader> _parseHeaders(String? jsonStr) {
@@ -2973,3 +2998,23 @@ class EmailRepositoryImpl implements EmailRepository {
}
}
}
/// Recursively converts an [imap.MimePart] into a JSON-serialisable map.
Map<String, dynamic> _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));