diff --git a/DB-SYNC.md b/DB-SYNC.md index fc2bf65..b3496b2 100644 --- a/DB-SYNC.md +++ b/DB-SYNC.md @@ -27,6 +27,10 @@ This document covers the mail-to-database sync layer only, not the UI. at the start of each sync cycle. - Email bodies are fetched on demand via `Email/get` with `bodyValues` and cached in `email_bodies` so subsequent opens are instant. +- `syncEmails` fetches `bodyValues` during the sync pass so bodies are cached without + a separate on-demand fetch. +- `flushPendingChanges` passes `ifInState` to every `Email/set`; a `stateMismatch` + response clears the local checkpoint and triggers a full re-sync before retrying. ### IMAP @@ -56,18 +60,15 @@ This document covers the mail-to-database sync layer only, not the UI. ### JMAP hardening -- **Body caching during sync**: `syncEmails` currently syncs headers only. Include - `bodyValues` + `htmlBody`/`textBody` in the `Email/get` properties list so bodies - are written to `email_bodies` during the sync pass, not just on first open. - **JMAP send**: implement outgoing mail via `EmailSubmission/set` in addition to the current SMTP path. - **Push instead of polling**: upgrade `_JmapAccountSync._wait()` to use an `EventSource` connection to the JMAP push URL when the server advertises push capability. Fall back to 30 s polling when push is unavailable. -- **Conflict handling**: pass `ifInState` to `Email/set` in `flushPendingChanges` so - the server can reject a stale mutation; retry the affected change after re-syncing. ### Shared / cross-protocol -- **Explicit conflict-resolution strategy**: decide and document the policy (last-write- - wins vs. server-wins) and implement it consistently across both protocols. +- **Conflict-resolution hardening**: document and enforce the server-wins policy + consistently — check `notUpdated`/`notDestroyed` per-item errors in JMAP `Email/set` + responses, handle IMAP `NO`/`BAD` gracefully, and evict changes that exceed a + maximum retry threshold (e.g. 5 attempts) to prevent queues from growing unboundedly. diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index f7b8cbe..5c80475 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -39,6 +39,8 @@ class Mailboxes extends Table { TextColumn get name => text()(); IntColumn get unreadCount => integer().withDefault(const Constant(0))(); IntColumn get totalCount => integer().withDefault(const Constant(0))(); + // Added in schema v8: JMAP role (e.g. "inbox", "sent", "trash"). + TextColumn get role => text().nullable()(); @override Set get primaryKey => {id}; @@ -150,7 +152,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration => MigrationStrategy( @@ -174,6 +176,9 @@ class AppDatabase extends _$AppDatabase { if (from < 7) { await m.createTable(syncLogs); } + if (from < 8) { + await m.addColumn(mailboxes, mailboxes.role); + } }, ); } diff --git a/lib/data/jmap/jmap_client.dart b/lib/data/jmap/jmap_client.dart index 6df99ed..85fe054 100644 --- a/lib/data/jmap/jmap_client.dart +++ b/lib/data/jmap/jmap_client.dart @@ -1,12 +1,15 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; -const _using = [ +const _coreUsing = [ 'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', ]; +const _submissionCapability = 'urn:ietf:params:jmap:submission'; + /// A connected JMAP session. Fetch via [JmapClient.connect]. /// /// Parses the JMAP Session object (RFC 8620 §2), stores the resolved @@ -17,18 +20,33 @@ class JmapClient { required String credentials, required Uri apiUrl, required String accountId, + required Set capabilities, + String? uploadUrl, + String? eventSourceUrl, }) : _httpClient = httpClient, _credentials = credentials, _apiUrl = apiUrl, - _accountId = accountId; + _accountId = accountId, + _capabilities = capabilities, + _uploadUrl = uploadUrl, + _eventSourceUrl = eventSourceUrl; final http.Client _httpClient; final String _credentials; final Uri _apiUrl; final String _accountId; + final Set _capabilities; + final String? _uploadUrl; + final String? _eventSourceUrl; String get accountId => _accountId; + /// Whether the server supports `EmailSubmission/set` (RFC 8621 §7). + bool get supportsSubmission => _capabilities.contains(_submissionCapability); + + /// SSE push URL advertised by the server, or null if push is unsupported. + String? get eventSourceUrl => _eventSourceUrl; + /// Fetches the JMAP Session object from [jmapUrl] and returns a connected /// client. Throws [JmapException] on HTTP errors or missing capabilities. static Future connect({ @@ -54,11 +72,18 @@ class JmapClient { final apiUrl = _extractApiUrl(session, jmapUrl); final accountId = _extractAccountId(session); + final capabilities = _extractCapabilities(session); + final uploadUrl = session['uploadUrl'] as String?; + final eventSourceUrl = session['eventSourceUrl'] as String?; + return JmapClient._( httpClient: httpClient, credentials: credentials, apiUrl: apiUrl, accountId: accountId, + capabilities: capabilities, + uploadUrl: uploadUrl, + eventSourceUrl: eventSourceUrl, ); } @@ -67,10 +92,19 @@ class JmapClient { /// Each call is a triple `[methodName, arguments, callId]`. /// Returns the raw `methodResponses` list from the server. /// + /// Pass [withSubmission] to include `urn:ietf:params:jmap:submission` in + /// the `using` declaration (required for `EmailSubmission/set` calls). + /// /// Throws [JmapException] on HTTP errors or a top-level JMAP error response. - Future> call(List> methodCalls) async { + Future> call( + List> methodCalls, { + bool withSubmission = false, + }) async { + final using = withSubmission + ? [..._coreUsing, _submissionCapability] + : _coreUsing; final body = jsonEncode({ - 'using': _using, + 'using': using, 'methodCalls': methodCalls, }); @@ -100,6 +134,34 @@ class JmapClient { return decoded['methodResponses'] as List; } + /// Uploads [data] as a blob and returns the server-assigned `blobId`. + /// + /// Used to attach files to outgoing emails before calling `Email/set`. + Future uploadBlob(Uint8List data, String contentType) async { + if (_uploadUrl == null) { + throw JmapException('Server does not advertise an uploadUrl'); + } + final url = Uri.parse( + _uploadUrl.replaceAll('{accountId}', Uri.encodeComponent(_accountId))); + final resp = await _httpClient + .post( + url, + headers: { + 'Authorization': 'Basic $_credentials', + 'Content-Type': contentType, + }, + body: data, + ) + .timeout(const Duration(seconds: 30)); + if (resp.statusCode != 200 && resp.statusCode != 201) { + throw JmapException('Blob upload failed (HTTP ${resp.statusCode})'); + } + final decoded = jsonDecode(resp.body) as Map; + final blobId = decoded['blobId'] as String?; + if (blobId == null) throw JmapException('Blob upload: missing blobId'); + return blobId; + } + static Uri _extractApiUrl(Map session, Uri sessionUri) { final raw = session['apiUrl'] as String?; if (raw == null || raw.isEmpty) { @@ -109,6 +171,11 @@ class JmapClient { return sessionUri.resolve(raw); } + static Set _extractCapabilities(Map session) { + final caps = session['capabilities'] as Map?; + return caps?.keys.toSet() ?? {}; + } + static String _extractAccountId(Map session) { final primaryAccounts = session['primaryAccounts'] as Map?; diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 98ee1bc..5a2ddd2 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -953,6 +953,16 @@ class EmailRepositoryImpl implements EmailRepository { Future sendEmail(String accountId, model.EmailDraft draft) async { final account = (await _accounts.getAccount(accountId))!; final password = await _accounts.getPassword(accountId); + switch (account.type) { + case account_model.AccountType.imap: + await _sendEmailImap(account, password, draft); + case account_model.AccountType.jmap: + await _sendEmailJmap(account, password, draft); + } + } + + Future _sendEmailImap(account_model.Account account, String password, + model.EmailDraft draft) async { final builder = imap.MessageBuilder() ..from = [imap.MailAddress(draft.from.name, draft.from.email)] ..to = draft.to.map((a) => imap.MailAddress(a.name, a.email)).toList() @@ -965,7 +975,8 @@ class EmailRepositoryImpl implements EmailRepository { await builder.addFile(file, mediaType); } final mimeMessage = builder.buildMimeMessage(); - final smtpClient = await _smtpConnect(account, _effectiveUsername(account), password); + final smtpClient = + await _smtpConnect(account, _effectiveUsername(account), password); try { await smtpClient.sendMessage(mimeMessage); } finally { @@ -973,7 +984,8 @@ class EmailRepositoryImpl implements EmailRepository { } // Save a copy to the Sent folder via IMAP APPEND. // Create the folder first — many servers don't pre-create it. - final imapClient = await _imapConnect(account, _effectiveUsername(account), password); + final imapClient = + await _imapConnect(account, _effectiveUsername(account), password); try { try { await imapClient.createMailbox('Sent'); @@ -990,6 +1002,124 @@ class EmailRepositoryImpl implements EmailRepository { } } + Future _sendEmailJmap(account_model.Account account, String password, + model.EmailDraft draft) async { + final jmapUrl = account.jmapUrl; + if (jmapUrl == null || jmapUrl.isEmpty) { + throw Exception('JMAP account ${account.id} has no jmapUrl'); + } + final jmap = await JmapClient.connect( + httpClient: _httpClient, + jmapUrl: Uri.parse(jmapUrl), + username: _effectiveUsername(account), + password: password, + ); + + // Upload any file attachments and collect their blobIds. + final attachments = >[]; + for (final filePath in draft.attachmentFilePaths) { + final file = File(filePath); + final bytes = await file.readAsBytes(); + final contentType = imap.MediaType.guessFromFileName(filePath).text; + final blobId = await jmap.uploadBlob(bytes, contentType); + attachments.add({ + 'blobId': blobId, + 'type': contentType, + 'name': p.basename(filePath), + 'size': bytes.length, + 'disposition': 'attachment', + }); + } + + // Look up the Sent mailbox JMAP ID from the local DB. + final sentMailbox = await (_db.select(_db.mailboxes) + ..where((t) => + t.accountId.equals(account.id) & t.role.equals('sent')) + ..limit(1)) + .getSingleOrNull(); + final sentJmapId = sentMailbox?.path; + + // Build the email body. + const bodyPartId = '1'; + final emailCreate = { + 'from': [{'name': draft.from.name, 'email': draft.from.email}], + 'to': draft.to.map((a) => {'name': a.name, 'email': a.email}).toList(), + if (draft.cc.isNotEmpty) + 'cc': draft.cc.map((a) => {'name': a.name, 'email': a.email}).toList(), + 'subject': draft.subject, + 'bodyValues': { + bodyPartId: { + 'value': draft.body, + 'isEncodingProblem': false, + 'isTruncated': false, + }, + }, + 'textBody': [{'partId': bodyPartId, 'type': 'text/plain'}], + if (attachments.isNotEmpty) 'attachments': attachments, + 'keywords': {r'$seen': true}, + if (sentJmapId != null) 'mailboxIds': {sentJmapId: true}, + }; + + // Build the recipient envelope for EmailSubmission. + final allRecipients = [ + ...draft.to.map((a) => {'email': a.email}), + ...draft.cc.map((a) => {'email': a.email}), + ]; + + // Chain Email/set (create) + EmailSubmission/set (create) in one request. + final responses = await jmap.call( + [ + [ + 'Email/set', + { + 'accountId': jmap.accountId, + 'create': {'em1': emailCreate}, + }, + '0', + ], + [ + 'EmailSubmission/set', + { + 'accountId': jmap.accountId, + 'create': { + 'sub1': { + '#emailId': { + 'resultOf': '0', + 'name': 'Email/set', + 'path': '/created/em1/id', + }, + 'envelope': { + 'mailFrom': {'email': draft.from.email}, + 'rcptTo': allRecipients, + }, + }, + }, + }, + '1', + ], + ], + withSubmission: true, + ); + + // Check Email/set for creation errors. + final setResult = _responseArgs(responses, 0, 'Email/set'); + final notCreated = + setResult['notCreated'] as Map?; + if (notCreated != null && notCreated.containsKey('em1')) { + final err = notCreated['em1'] as Map; + throw JmapException('Email/set create failed: ${err['type']}'); + } + + // Check EmailSubmission/set for submission errors. + final subResult = _responseArgs(responses, 1, 'EmailSubmission/set'); + final notSubmitted = + subResult['notCreated'] as Map?; + if (notSubmitted != null && notSubmitted.containsKey('sub1')) { + final err = notSubmitted['sub1'] as Map; + throw JmapException('EmailSubmission/set failed: ${err['type']}'); + } + } + @override Future downloadAttachment( String emailId, diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index ae4983b..f1df0a5 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -195,6 +195,7 @@ class MailboxRepositoryImpl implements MailboxRepository { name: m['name'] as String? ?? jmapId, unreadCount: Value((m['unreadEmails'] as int?) ?? 0), totalCount: Value((m['totalEmails'] as int?) ?? 0), + role: Value(m['role'] as String?), ), ); } diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 473663d..2021c97 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -10,6 +10,7 @@ import 'package:http/testing.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/data/db/database.dart' hide Account; +import 'package:sharedinbox/data/jmap/jmap_client.dart'; import 'package:sharedinbox/data/repositories/account_repository_impl.dart'; import 'package:sharedinbox/data/repositories/email_repository_impl.dart'; @@ -1469,4 +1470,169 @@ void main() { expect(bodies, isEmpty); }); }); + + group('JMAP sendEmail', () { + http.Client mockSend({ + int sessionStatus = 200, + int apiStatus = 200, + Map? emailSetResult, + Map? submissionResult, + }) { + return MockClient((req) async { + if (req.url.path.contains('well-known')) { + return http.Response( + jsonEncode({ + 'apiUrl': 'https://jmap.example.com/api/', + 'accounts': {'acct1': {}}, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': 'acct1', + 'urn:ietf:params:jmap:mail': 'acct1', + }, + 'capabilities': { + 'urn:ietf:params:jmap:core': {}, + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:submission': {}, + }, + 'username': 'alice@example.com', + 'state': 'sess1', + }), + sessionStatus, + ); + } + return http.Response( + jsonEncode({ + 'sessionState': 's1', + 'methodResponses': [ + [ + 'Email/set', + emailSetResult ?? + { + 'accountId': 'acct1', + 'newState': 'est2', + 'created': {'em1': {'id': 'newEmailId1'}}, + }, + '0', + ], + [ + 'EmailSubmission/set', + submissionResult ?? + { + 'accountId': 'acct1', + 'created': {'sub1': {'id': 'subId1'}}, + }, + '1', + ], + ], + }), + apiStatus, + ); + }); + } + + const draft = EmailDraft( + from: EmailAddress(name: 'Alice', email: 'alice@example.com'), + to: [EmailAddress(name: 'Bob', email: 'bob@example.com')], + cc: [], + subject: 'Hello', + body: 'World', + ); + + test('sends email via EmailSubmission/set for JMAP accounts', () async { + final r = _makeRepos(httpClient: mockSend()); + await r.accounts.addAccount(_jmapAccount, 'pw'); + + await r.emails.sendEmail('jmap-1', draft); + // No exception = success; IMAP connections are not opened + }); + + test('throws when Email/set reports notCreated', () async { + final r = _makeRepos( + httpClient: mockSend( + emailSetResult: { + 'accountId': 'acct1', + 'notCreated': {'em1': {'type': 'invalidProperties'}}, + }, + ), + ); + await r.accounts.addAccount(_jmapAccount, 'pw'); + + await expectLater( + r.emails.sendEmail('jmap-1', draft), + throwsA(isA()), + ); + }); + + test('throws when EmailSubmission/set reports notCreated', () async { + final r = _makeRepos( + httpClient: mockSend( + submissionResult: { + 'accountId': 'acct1', + 'notCreated': {'sub1': {'type': 'invalidRecipients'}}, + }, + ), + ); + await r.accounts.addAccount(_jmapAccount, 'pw'); + + await expectLater( + r.emails.sendEmail('jmap-1', draft), + throwsA(isA()), + ); + }); + + test('uses Sent mailbox ID when role=sent mailbox exists in DB', () async { + late Map capturedBody; + final client = MockClient((req) async { + if (req.url.path.contains('well-known')) { + return http.Response( + jsonEncode({ + 'apiUrl': 'https://jmap.example.com/api/', + 'accounts': {'acct1': {}}, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': 'acct1', + 'urn:ietf:params:jmap:mail': 'acct1', + }, + 'capabilities': { + 'urn:ietf:params:jmap:core': {}, + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:submission': {}, + }, + 'username': 'alice@example.com', + 'state': 'sess1', + }), + 200, + ); + } + capturedBody = jsonDecode(req.body) as Map; + return http.Response( + jsonEncode({ + 'sessionState': 's1', + 'methodResponses': [ + ['Email/set', {'accountId': 'acct1', 'newState': 'est2', + 'created': {'em1': {'id': 'newId'}}}, '0'], + ['EmailSubmission/set', {'accountId': 'acct1', + 'created': {'sub1': {'id': 'subId'}}}, '1'], + ], + }), + 200, + ); + }); + + final r = _makeRepos(httpClient: client); + await r.accounts.addAccount(_jmapAccount, 'pw'); + // Seed a Sent mailbox with role='sent' + await r.db.into(r.db.mailboxes).insert(MailboxesCompanion.insert( + id: 'jmap-1:sentMbx', accountId: 'jmap-1', + path: 'sentMbxJmapId', name: 'Sent', + role: const Value('sent'), + )); + + await r.emails.sendEmail('jmap-1', draft); + + final calls = capturedBody['methodCalls'] as List; + final emailSetArgs = (calls.first as List)[1] as Map; + final createMap = emailSetArgs['create'] as Map; + final em1Create = createMap['em1'] as Map; + expect(em1Create['mailboxIds'], {'sentMbxJmapId': true}); + }); + }); }