fix(email): populate mimeTree for JMAP accounts in Show Mail Structure (#92)

The JMAP body-fetch path never requested or stored `bodyStructure`, so
`body.mimeTree` was always null for JMAP accounts — causing Show Mail
Structure to show nothing.

Fix: include `bodyStructure` in the JMAP `Email/get` request and convert
it to the same JSON format used by the IMAP path via the new
`_jmapBodyStructureToJson` helper.  The parsed tree is persisted in the
DB and returned from `getEmailBody`, so the cached round-trip also works.

Tests added:
- Unit: JMAP getEmailBody populates mimeTree from bodyStructure and
  survives the cache round-trip; null when bodyStructure is absent.
- Widget: Show Mail Structure dialog displays all MIME parts when
  mimeTree is present; snackbar appears when mimeTree is null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-15 14:23:43 +02:00
co-authored by Claude Sonnet 4.6
parent 451aceaeed
commit ef3fb72f4e
3 changed files with 226 additions and 0 deletions
@@ -318,9 +318,17 @@ class EmailRepositoryImpl implements EmailRepository {
'htmlBody',
'bodyValues',
'attachments',
'bodyStructure',
],
'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true,
'bodyProperties': [
'partId',
'type',
'name',
'size',
'subParts',
],
},
'0',
],
@@ -340,6 +348,12 @@ class EmailRepositoryImpl implements EmailRepository {
}).toList(),
);
final rawBodyStructure =
emailData['bodyStructure'] as Map<String, dynamic>?;
final mimeTreeJson = rawBodyStructure != null
? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure))
: null;
await _db.into(_db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert(
emailId: emailId,
@@ -347,6 +361,7 @@ class EmailRepositoryImpl implements EmailRepository {
htmlBody: Value(htmlBody),
attachmentsJson: Value(attachmentsJson),
headersJson: Value(headersJson),
mimeTreeJson: Value(mimeTreeJson),
cachedAt: Value(DateTime.now()),
),
);
@@ -357,6 +372,7 @@ class EmailRepositoryImpl implements EmailRepository {
htmlBody: htmlBody,
attachments: _parseAttachments(attachmentsJson),
headers: _parseHeaders(headersJson),
mimeTree: _parseMimeTree(mimeTreeJson),
);
}
@@ -3018,3 +3034,16 @@ Map<String, dynamic> _mimePartToJson(imap.MimePart part) {
/// Builds a JSON string representing the MIME tree of [msg].
String _buildMimeTreeJson(imap.MimeMessage msg) =>
jsonEncode(_mimePartToJson(msg));
/// Converts a JMAP `bodyStructure` object into the same JSON format used by
/// [_mimePartToJson], so [_parseMimeTree] can deserialise it uniformly.
Map<String, dynamic> _jmapBodyStructureToJson(Map<String, dynamic> m) => {
'contentType': m['type'] as String? ?? 'application/octet-stream',
'filename': m['name'],
'size': m['size'],
'encoding': null,
'children': ((m['subParts'] as List<dynamic>?) ?? [])
.cast<Map<String, dynamic>>()
.map(_jmapBodyStructureToJson)
.toList(),
};
+109
View File
@@ -878,6 +878,115 @@ void main() {
expect(body.textBody, isNull);
expect(body.htmlBody, isNull);
});
test('populates mimeTree from JMAP bodyStructure', () async {
final r = _makeRepos(
httpClient: MockClient((req) async {
if (req.url.path.contains('well-known')) {
return http.Response(
jsonEncode({
'apiUrl': 'https://jmap.example.com/api/',
'accounts': {
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
},
'primaryAccounts': {
'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1',
},
'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
return http.Response(
jsonEncode({
'sessionState': 'sess1',
'methodResponses': [
[
'Email/get',
{
'accountId': 'acct1',
'state': 'es1',
'list': [
{
'id': 'e1',
'textBody': [
{'partId': '1', 'type': 'text/plain'},
],
'htmlBody': [],
'bodyValues': {
'1': {'value': 'Hello', 'isTruncated': false},
},
'attachments': [],
'bodyStructure': {
'type': 'multipart/mixed',
'subParts': [
{'type': 'text/plain', 'size': 5},
{
'type': 'application/pdf',
'name': 'doc.pdf',
'size': 2048,
},
],
},
},
],
},
'0',
],
],
}),
200,
);
}),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'jmap-1:e1',
accountId: 'jmap-1',
mailboxPath: 'mbx1',
uid: 0,
receivedAt: DateTime(2024),
),
);
final body = await r.emails.getEmailBody('jmap-1:e1');
expect(body.mimeTree, isNotNull);
expect(body.mimeTree!.contentType, 'multipart/mixed');
expect(body.mimeTree!.children, hasLength(2));
expect(body.mimeTree!.children[0].contentType, 'text/plain');
expect(body.mimeTree!.children[1].contentType, 'application/pdf');
expect(body.mimeTree!.children[1].filename, 'doc.pdf');
expect(body.mimeTree!.children[1].size, 2048);
// mimeTree must survive the cache round-trip.
final cached = await r.emails.getEmailBody('jmap-1:e1');
expect(cached.mimeTree, isNotNull);
expect(cached.mimeTree!.contentType, 'multipart/mixed');
expect(cached.mimeTree!.children, hasLength(2));
});
test('mimeTree is null when bodyStructure is absent', () async {
final r = _makeRepos(httpClient: mockBodyClient());
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'jmap-1:e1',
accountId: 'jmap-1',
mailboxPath: 'mbx1',
uid: 0,
receivedAt: DateTime(2024),
),
);
// mockBodyClient returns no bodyStructure field.
final body = await r.emails.getEmailBody('jmap-1:e1');
expect(body.mimeTree, isNull);
});
});
group('JMAP syncEmails', () {
+88
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
@@ -8,6 +9,20 @@ import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Shared overrides for email detail tests.
List<Override> _overrides({required EmailBody body, Email? email}) => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emailDetail: email ?? testEmail(),
emailBody: body,
),
),
];
void main() {
group('EmailDetailScreen', () {
testWidgets('shows loading spinner before data arrives', (tester) async {
@@ -127,6 +142,79 @@ void main() {
expect(find.text('Attachments'), findsOneWidget);
expect(find.text('report.pdf'), findsOneWidget);
});
testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester,
) async {
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Hello',
attachments: [],
mimeTree: MimePart(
contentType: 'multipart/mixed',
children: [
MimePart(contentType: 'text/plain', size: 100),
MimePart(
contentType: 'application/pdf',
filename: 'report.pdf',
size: 204800,
),
],
),
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
// Open the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
// Tap the structure item.
await tester.tap(find.text('Show Mail Structure'));
await tester.pumpAndSettle();
// The dialog title and all three MIME parts must be visible.
expect(find.text('Mail Structure'), findsOneWidget);
expect(find.textContaining('multipart/mixed'), findsOneWidget);
expect(find.textContaining('text/plain'), findsOneWidget);
expect(find.textContaining('application/pdf'), findsOneWidget);
});
testWidgets(
'Show Mail Structure shows snackbar when mimeTree is absent',
(tester) async {
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Hello',
attachments: [],
// mimeTree is null — not yet cached or not available.
);
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Show Mail Structure'));
await tester.pumpAndSettle();
expect(
find.textContaining('Structure not available'),
findsOneWidget,
);
},
);
});
}