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:
co-authored by
Claude Sonnet 4.6
parent
451aceaeed
commit
ef3fb72f4e
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user