import 'dart:async'; import 'dart:convert'; import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:enough_mail/enough_mail.dart' as imap; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; 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'; import 'account_repository_impl_test.dart' show MapSecureStorage; import 'db_test_helper.dart'; import 'fake_imap.dart' show FakeImapClient, SnoozeSpyImapClient; // ── Helpers ─────────────────────────────────────────────────────────────────── const _account = Account( id: 'acc-1', displayName: 'Alice', email: 'alice@example.com', imapHost: 'imap.example.com', smtpHost: 'smtp.example.com', ); const _jmapAccount = Account( id: 'jmap-1', displayName: 'Alice', email: 'alice@example.com', type: AccountType.jmap, jmapUrl: 'https://jmap.example.com/.well-known/jmap', ); http.Client _mockJmapEmails({ required List> apiResponses, }) { var callIndex = 0; return 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, ); } final resp = apiResponses[callIndex % apiResponses.length]; callIndex++; return http.Response(jsonEncode(resp), 200); }); } Map _emailGetResponse({ required String state, required List> list, int? total, }) => { 'sessionState': 'sess1', 'methodResponses': [ [ 'Email/query', { 'accountId': 'acct1', 'ids': list.map((e) => e['id']).toList(), 'total': total ?? list.length, }, '0', ], [ 'Email/get', {'accountId': 'acct1', 'state': state, 'list': list}, '1', ], ], }; Map _emailChangesResponse({ required String oldState, required String newState, List created = const [], List updated = const [], List destroyed = const [], }) => { 'sessionState': 'sess1', 'methodResponses': [ [ 'Email/changes', { 'accountId': 'acct1', 'oldState': oldState, 'newState': newState, 'hasMoreChanges': false, 'created': created, 'updated': updated, 'destroyed': destroyed, }, '0', ], ], }; Map _emailGetOnly({ required String state, required List> list, }) => { 'sessionState': 'sess1', 'methodResponses': [ [ 'Email/get', {'accountId': 'acct1', 'state': state, 'list': list}, '1', ], ], }; Map _jmapEmail({ required String id, required String mailboxId, String subject = 'Hello', bool seen = false, String? threadId, }) => { 'id': id, 'mailboxIds': {mailboxId: true}, 'subject': subject, 'sentAt': '2024-01-01T10:00:00Z', 'receivedAt': '2024-01-01T10:00:01Z', 'from': [ {'name': 'Sender', 'email': 'sender@example.com'}, ], 'to': [ {'name': 'Alice', 'email': 'alice@example.com'}, ], 'cc': [], 'keywords': seen ? {r'$seen': true} : {}, 'hasAttachment': false, 'preview': 'Hello world', 'threadId': threadId, }; Future _noImapConnect(Account a, String u, String p) => Future.error(UnsupportedError('IMAP unavailable in unit tests')); Future _noSmtpConnect(Account a, String u, String p) => Future.error(UnsupportedError('SMTP unavailable in unit tests')); ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) _makeRepos({ http.Client? httpClient, Future Function(Account, String, String)? imapConnect, Future Function(Account, String, String)? smtpConnect, }) { final db = openTestDatabase(); final storage = MapSecureStorage(); final accounts = AccountRepositoryImpl(db, storage); final emails = EmailRepositoryImpl( db, accounts, imapConnect: imapConnect ?? _noImapConnect, smtpConnect: smtpConnect ?? _noSmtpConnect, httpClient: httpClient, ); return (db: db, accounts: accounts, emails: emails); } // ── Tests ───────────────────────────────────────────────────────────────────── void main() { setUpAll(configureSqliteForTests); group('EmailRepositoryImpl', () { test('observeEmails emits empty list when no emails', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); final emails = await r.emails.observeEmails('acc-1', 'INBOX').first; expect(emails, isEmpty); }); test('getEmail returns null for unknown id', () async { final r = _makeRepos(); expect(await r.emails.getEmail('no-such-id'), isNull); }); test('observeEmails reflects inserted row', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:42', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 42, receivedAt: DateTime(2024), ), ); final emails = await r.emails.observeEmails('acc-1', 'INBOX').first; expect(emails, hasLength(1)); expect(emails.first.id, 'acc-1:42'); expect(emails.first.uid, 42); }); test('getEmail returns inserted row', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:7', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 7, receivedAt: DateTime(2024, 6, 15), ), ); final email = await r.emails.getEmail('acc-1:7'); expect(email, isNotNull); expect(email!.mailboxPath, 'INBOX'); }); test('observeEmails orders by receivedAt descending', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); for (final (uid, date) in [ (1, DateTime(2024)), (3, DateTime(2024, 3)), (2, DateTime(2024, 2)), ]) { await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:$uid', accountId: 'acc-1', mailboxPath: 'INBOX', uid: uid, receivedAt: date, ), ); } final emails = await r.emails.observeEmails('acc-1', 'INBOX').first; expect(emails.map((e) => e.uid).toList(), [3, 2, 1]); }); test('syncEmails propagates IMAP error', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); expect( () => r.emails.syncEmails('acc-1', 'INBOX'), throwsA(isA()), ); }); test('getEmailBody propagates IMAP error when not cached', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, receivedAt: DateTime(2024), ), ); expect( () => r.emails.getEmailBody('acc-1:1'), throwsA(isA()), ); }); test('getEmailBody returns cached body without IMAP call', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emailBodies).insert( EmailBodiesCompanion.insert( emailId: 'acc-1:1', textBody: const Value('Hello'), htmlBody: const Value('

Hello

'), cachedAt: Value(DateTime.now()), ), ); final body = await r.emails.getEmailBody('acc-1:1'); expect(body.textBody, 'Hello'); expect(body.htmlBody, '

Hello

'); }); // ── Threading tests ────────────────────────────────────────────────────── test('observeThreads returns aggregated thread rows from DB', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); final now = DateTime.now(); await r.db.into(r.db.threads).insert( ThreadsCompanion.insert( id: 'tid1', accountId: 'acc-1', mailboxPath: 'INBOX', subject: const Value('Thread 1'), latestDate: now, messageCount: const Value(2), hasUnread: const Value(true), latestEmailId: 'acc-1:2', emailIdsJson: const Value('["acc-1:1", "acc-1:2"]'), ), ); final threads = await r.emails.observeThreads('acc-1', 'INBOX').first; expect(threads, hasLength(1)); expect(threads.first.threadId, 'tid1'); expect(threads.first.subject, 'Thread 1'); expect(threads.first.messageCount, 2); expect(threads.first.hasUnread, isTrue); expect(threads.first.emailIds, ['acc-1:1', 'acc-1:2']); }); test('observeEmailsInThread returns all emails for a thread', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, threadId: const Value('tid1'), receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 2, threadId: const Value('tid1'), receivedAt: DateTime(2024, 2), ), ); final emails = await r.emails.observeEmailsInThread('acc-1', 'INBOX', 'tid1').first; expect(emails, hasLength(2)); expect(emails.map((e) => e.id).toSet(), {'acc-1:1', 'acc-1:2'}); }); // ── Search tests ───────────────────────────────────────────────────────── test('searchEmailsGlobal filters by query across accounts', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount( _account.copyWith(id: 'acc-2', email: 'bob@example.com'), 'pw', ); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, subject: const Value('Pizza night'), receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-2:1', accountId: 'acc-2', mailboxPath: 'INBOX', uid: 1, subject: const Value('Burger lunch'), receivedAt: DateTime(2024), ), ); // Global search final results1 = await r.emails.searchEmailsGlobal(null, 'pizza'); expect(results1, hasLength(1)); expect(results1.first.subject, 'Pizza night'); // Account-specific search final results2 = await r.emails.searchEmailsGlobal('acc-2', 'burger'); expect(results2, hasLength(1)); expect(results2.first.subject, 'Burger lunch'); final results3 = await r.emails.searchEmailsGlobal('acc-1', 'burger'); expect(results3, isEmpty); }); test('searchEmailsGlobal matches word prefix but not suffix', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, subject: const Value('foobar baz'), receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 2, subject: const Value('blafoo baz'), receivedAt: DateTime(2024), ), ); // 'foo' is a prefix of 'foobar' — should match; 'blafoo' is not a // prefix match so only one result expected. final results = await r.emails.searchEmailsGlobal(null, 'foo'); expect(results, hasLength(1)); expect(results.first.subject, 'foobar baz'); }); test('searchEmails filters by mailboxPath using local FTS5', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); // Insert matching email in INBOX. await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, subject: const Value('Meeting agenda'), receivedAt: DateTime(2024), ), ); // Insert matching email in a different mailbox — must not appear. await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', mailboxPath: 'Sent', uid: 2, subject: const Value('Meeting follow-up'), receivedAt: DateTime(2024), ), ); final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting'); expect(results, hasLength(1)); expect(results.first.subject, 'Meeting agenda'); expect(results.first.mailboxPath, 'INBOX'); }); test( 'searchAddresses returns results sorted by most recently used', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); final older = DateTime(2024); final newer = DateTime(2024, 6); // Two emails — older one has alice@, newer one has bob@. await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:old', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, receivedAt: older, toAddresses: const Value( '[{"name":"Alice","email":"alice@example.com"}]', ), ), ); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:new', accountId: 'acc-1', mailboxPath: 'Sent', uid: 2, receivedAt: newer, toAddresses: const Value( '[{"name":"Bob","email":"bob@example.com"}]', ), ), ); // Query matching both; newer (bob) should come first. final results = await r.emails.searchAddresses(null, 'example'); expect(results.map((a) => a.email).toList(), [ 'bob@example.com', 'alice@example.com', ]); }, ); test( 'searchAddresses prioritises sent-folder addresses over newer received', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); // Register the Sent mailbox so searchAddresses knows its role. await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:Sent', accountId: 'acc-1', path: 'Sent', name: 'Sent', role: const Value('sent'), ), ); // Older sent email: user deliberately wrote to info@foo.de. await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:sent-1', accountId: 'acc-1', mailboxPath: 'Sent', uid: 1, receivedAt: DateTime(2025), toAddresses: const Value( '[{"name":"Foo","email":"info@foo.de"}]', ), ), ); // Newer received email: spam arrived today from info@spam.de. await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:inbox-1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 2, receivedAt: DateTime(2026), fromJson: const Value( '[{"name":"Spam","email":"info@spam.de"}]', ), ), ); // Even though spam is newer, the sent-folder address should win. final results = await r.emails.searchAddresses(null, 'info'); expect(results.map((a) => a.email).toList(), [ 'info@foo.de', 'info@spam.de', ]); }, ); // ── IMAP method tests ──────────────────────────────────────────────────── test( 'setFlag seen=true enqueues flag_seen change and updates local DB', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), ), ); await r.emails.setFlag('acc-1:5', seen: true); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(1)); expect(changes.first.changeType, 'flag_seen'); expect(changes.first.payload, contains('"seen":true')); final email = await r.emails.getEmail('acc-1:5'); expect(email!.isSeen, isTrue); }, ); test( 'setFlag seen=false enqueues flag_seen change with seen=false', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), isSeen: const Value(true), ), ); await r.emails.setFlag('acc-1:5', seen: false); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'flag_seen'); expect(changes.first.payload, contains('"seen":false')); final email = await r.emails.getEmail('acc-1:5'); expect(email!.isSeen, isFalse); }, ); test('setFlag flagged=true enqueues flag_flagged change', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), ), ); await r.emails.setFlag('acc-1:5', flagged: true); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'flag_flagged'); final email = await r.emails.getEmail('acc-1:5'); expect(email!.isFlagged, isTrue); }); test( 'setFlag flagged=false enqueues flag_flagged change with flagged=false', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), isFlagged: const Value(true), ), ); await r.emails.setFlag('acc-1:5', flagged: false); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'flag_flagged'); expect(changes.first.payload, contains('"flagged":false')); }, ); test( 'moveEmail enqueues move change and updates local mailboxPath (optimistic)', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), ), ); await r.emails.moveEmail('acc-1:5', 'Archive'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'move'); expect(changes.first.payload, contains('Archive')); final email = await r.emails.getEmail('acc-1:5'); expect(email, isNotNull); expect(email!.mailboxPath, 'Archive'); }, ); test( 'deleteEmail enqueues delete change and removes email from local DB', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), ), ); await r.emails.deleteEmail('acc-1:5'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'delete'); expect(await r.emails.getEmail('acc-1:5'), isNull); }, ); }); group('IMAP flushPendingChanges', () { test('records attempt and error when IMAP throws', () async { final r = _makeRepos(); // _makeRepos uses _noImapConnect which throws UnsupportedError await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'Email', resourceId: 'acc-1:5', changeType: 'flag_seen', payload: '{"uid":5,"mailboxPath":"INBOX","seen":true}', createdAt: DateTime.now(), ), ); await r.emails.flushPendingChanges('acc-1', 'pw'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(1)); expect(changes.first.attempts, 1); expect(changes.first.lastError, isNotNull); }); test('evicts IMAP change after max attempts (5)', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); // Pre-seed a flag_seen at attempts=4 await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: _account.id, resourceType: 'Email', resourceId: '${_account.id}:1', changeType: 'flag_seen', payload: '{"uid":1,"mailboxPath":"INBOX","seen":true}', createdAt: DateTime.now(), attempts: const Value(4), ), ); // Force connection failure so the attempt counter increments final failingEmails = EmailRepositoryImpl( r.db, r.accounts, imapConnect: (_, __, ___) => Future.error(Exception('forced failure')), smtpConnect: _noSmtpConnect, ); await failingEmails.flushPendingChanges(_account.id, 'pw'); // 4+1 = 5 = _maxChangeAttempts → evicted expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test( 'snooze flush selects src mailbox and moves email to Snoozed', () async { final spy = SnoozeSpyImapClient(); final r = _makeRepos(imapConnect: (_, __, ___) async => spy); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'Snoozed', uid: 5, receivedAt: DateTime(2024), ), ); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'Email', resourceId: 'acc-1:5', changeType: 'snooze', payload: jsonEncode({ 'uid': 5, 'src': 'INBOX', 'dest': 'Snoozed', 'until': '2026-05-10T15:00:00.000', }), createdAt: DateTime.now(), ), ); await r.emails.flushPendingChanges('acc-1', 'pw'); // Change successfully applied — removed from queue. expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); // Source mailbox extracted from 'src', not 'mailboxPath'. expect(spy.selectedMailbox, 'INBOX'); expect(spy.createdMailbox, 'Snoozed'); expect(spy.movedToMailbox, 'Snoozed'); }, ); }); group('Snooze', () { test('snoozeEmail enqueues snooze change and updates local DB', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), ), ); final until = DateTime(2026, 5, 10, 15); await r.emails.snoozeEmail('acc-1:5', until); final email = await r.emails.getEmail('acc-1:5'); expect(email!.snoozedUntil, until); expect(email.mailboxPath, 'Snoozed'); expect(email.snoozedFromMailboxPath, 'INBOX'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(1)); expect(changes.first.changeType, 'snooze'); expect(changes.first.payload, contains('2026-05-10T15:00:00.000')); }); test('wakeUpEmails enqueues unsnooze for expired emails', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); // Seed Inbox mailbox await r.db.into(r.db.mailboxes).insert( MailboxesCompanion.insert( id: 'acc-1:INBOX', accountId: 'acc-1', path: 'INBOX', name: 'Inbox', role: const Value('inbox'), ), ); final past = DateTime.now().subtract(const Duration(hours: 1)); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'Snoozed', uid: 5, receivedAt: DateTime(2024), snoozedUntil: Value(past), snoozedFromMailboxPath: const Value('INBOX'), ), ); final count = await r.emails.wakeUpEmails('acc-1'); expect(count, 1); final email = await r.emails.getEmail('acc-1:5'); expect(email!.snoozedUntil, isNull); expect(email.mailboxPath, 'INBOX'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(1)); expect(changes.first.changeType, 'unsnooze'); }); }); group('JMAP getEmailBody', () { http.Client mockBodyClient({ String text = 'Hello from JMAP', String html = '

Hello from JMAP

', }) => 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': [ {'partId': '2', 'type': 'text/html'}, ], 'bodyValues': { '1': {'value': text, 'isTruncated': false}, '2': {'value': html, 'isTruncated': false}, }, 'attachments': [], }, ], }, '0', ], ], }), 200, ); }); test('fetches body via JMAP Email/get and caches it', () 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), ), ); final body = await r.emails.getEmailBody('jmap-1:e1'); expect(body.textBody, 'Hello from JMAP'); expect(body.htmlBody, '

Hello from JMAP

'); // Second call should return cached body without HTTP call. final cached = await r.emails.getEmailBody('jmap-1:e1'); expect(cached.textBody, body.textBody); }); test('returns empty body when bodyValues is absent', () 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': [], 'htmlBody': [], 'bodyValues': {}, 'attachments': [], }, ], }, '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.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', () { test('full sync upserts emails and persists state', () async { final r = _makeRepos( httpClient: _mockJmapEmails( apiResponses: [ _emailGetResponse( state: 'est1', list: [ _jmapEmail(id: 'e1', mailboxId: 'mbx1', subject: 'First'), _jmapEmail( id: 'e2', mailboxId: 'mbx1', subject: 'Second', seen: true, ), ], ), ], ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.emails.syncEmails('jmap-1', 'mbx1'); final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first; expect(emails, hasLength(2)); expect(emails.map((e) => e.subject).toSet(), {'First', 'Second'}); expect(emails.firstWhere((e) => e.subject == 'Second').isSeen, isTrue); final states = await r.db.select(r.db.syncStates).get(); expect(states, hasLength(1)); expect(states.first.state, 'est1'); }); test('incremental sync applies created, updated, destroyed', () async { final r = _makeRepos( httpClient: _mockJmapEmails( apiResponses: [ // Call 1: Email/changes _emailChangesResponse( oldState: 'est1', newState: 'est2', created: ['e3'], updated: ['e1'], destroyed: ['e2'], ), // Call 2: Email/get for created + updated _emailGetOnly( state: 'est2', list: [ _jmapEmail( id: 'e1', mailboxId: 'mbx1', subject: 'First updated', ), _jmapEmail(id: 'e3', mailboxId: 'mbx1', subject: 'Third'), ], ), ], ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); // Pre-populate await r.db.into(r.db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', mailboxPath: 'mbx1', uid: 0, subject: const Value('First'), receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: 'jmap-1:e2', accountId: 'jmap-1', mailboxPath: 'mbx1', uid: 0, subject: const Value('Second'), receivedAt: DateTime(2024), ), ); await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', state: 'est1', syncedAt: DateTime.now(), ), ); await r.emails.syncEmails('jmap-1', 'mbx1'); final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first; expect(emails.map((e) => e.subject).toSet(), {'First updated', 'Third'}); final states = await r.db.select(r.db.syncStates).get(); expect(states.first.state, 'est2'); }); test('incremental sync with no changes updates state only', () async { final r = _makeRepos( httpClient: _mockJmapEmails( apiResponses: [ _emailChangesResponse(oldState: 'est1', newState: 'est1'), ], ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', state: 'est1', syncedAt: DateTime.now(), ), ); await r.emails.syncEmails('jmap-1', 'mbx1'); final states = await r.db.select(r.db.syncStates).get(); expect(states.first.state, 'est1'); }); test('full sync paginates when total exceeds page size', () async { final page1 = [ _jmapEmail(id: 'e1', mailboxId: 'mbx1', subject: 'Page1-A'), _jmapEmail(id: 'e2', mailboxId: 'mbx1', subject: 'Page1-B'), ]; final page2 = [ _jmapEmail(id: 'e3', mailboxId: 'mbx1', subject: 'Page2-A'), _jmapEmail(id: 'e4', mailboxId: 'mbx1', subject: 'Page2-B'), ]; final r = _makeRepos( httpClient: _mockJmapEmails( apiResponses: [ _emailGetResponse(state: 'est1', list: page1, total: 4), _emailGetResponse(state: 'est1', list: page2, total: 4), ], ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.emails.syncEmails('jmap-1', 'mbx1'); final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first; expect(emails, hasLength(4)); expect(emails.map((e) => e.subject).toSet(), { 'Page1-A', 'Page1-B', 'Page2-A', 'Page2-B', }); final states = await r.db.select(r.db.syncStates).get(); expect(states.first.state, 'est1'); }); }); group('JMAP setFlag / moveEmail / deleteEmail enqueue pending_changes', () { Future seedJmapEmail( AppDatabase db, AccountRepositoryImpl accounts, ) async { await accounts.addAccount(_jmapAccount, 'pw'); await db.into(db.emails).insert( EmailsCompanion.insert( id: 'jmap-1:e1', accountId: 'jmap-1', mailboxPath: 'mbx1', uid: 0, receivedAt: DateTime(2024), ), ); } test( 'setFlag seen enqueues flag_seen change and updates local DB', () async { final r = _makeRepos(); await seedJmapEmail(r.db, r.accounts); await r.emails.setFlag('jmap-1:e1', seen: true); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(1)); expect(changes.first.changeType, 'flag_seen'); expect(changes.first.payload, contains('true')); final email = await r.emails.getEmail('jmap-1:e1'); expect(email?.isSeen, isTrue); }, ); test('setFlag flagged enqueues flag_flagged change', () async { final r = _makeRepos(); await seedJmapEmail(r.db, r.accounts); await r.emails.setFlag('jmap-1:e1', flagged: true); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'flag_flagged'); }); test( 'moveEmail enqueues move change and updates local mailbox path', () async { final r = _makeRepos(); await seedJmapEmail(r.db, r.accounts); await r.emails.moveEmail('jmap-1:e1', 'mbx2'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'move'); expect(changes.first.payload, contains('mbx2')); final email = await r.emails.getEmail('jmap-1:e1'); expect(email, isNotNull); expect(email?.mailboxPath, 'mbx2'); }, ); test( 'deleteEmail enqueues delete change and removes email from local DB', () async { final r = _makeRepos(); await seedJmapEmail(r.db, r.accounts); await r.emails.deleteEmail('jmap-1:e1'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.changeType, 'delete'); expect(await r.emails.getEmail('jmap-1:e1'), isNull); }, ); }); group('JMAP flushPendingChanges', () { http.Client mockFlush(int apiStatus) { return 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': 's1', 'methodResponses': [ [ 'Email/set', {'accountId': 'acct1', 'updated': {}, 'destroyed': []}, '0', ], ], }), apiStatus, ); }); } Future seedChange( AppDatabase db, AccountRepositoryImpl accounts, { String changeType = 'flag_seen', String payload = '{"seen":true}', }) async { await accounts.addAccount(_jmapAccount, 'pw'); await db.into(db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', resourceId: 'jmap-1:e1', changeType: changeType, payload: payload, createdAt: DateTime.now(), ), ); } test('no-op when no pending changes', () async { final r = _makeRepos(httpClient: mockFlush(200)); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.emails.flushPendingChanges('jmap-1', 'pw'); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test('sends flag_seen and removes change on success', () async { final r = _makeRepos(httpClient: mockFlush(200)); await seedChange(r.db, r.accounts); await r.emails.flushPendingChanges('jmap-1', 'pw'); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test('sends flag_flagged and removes change on success', () async { final r = _makeRepos(httpClient: mockFlush(200)); await seedChange( r.db, r.accounts, changeType: 'flag_flagged', payload: '{"flagged":true}', ); await r.emails.flushPendingChanges('jmap-1', 'pw'); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test('sends move and removes change on success', () async { final r = _makeRepos(httpClient: mockFlush(200)); await seedChange( r.db, r.accounts, changeType: 'move', payload: '{"src":"mbx1","dest":"mbx2"}', ); await r.emails.flushPendingChanges('jmap-1', 'pw'); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test('sends delete and removes change on success', () async { final r = _makeRepos(httpClient: mockFlush(200)); await seedChange(r.db, r.accounts, changeType: 'delete', payload: '{}'); await r.emails.flushPendingChanges('jmap-1', 'pw'); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test('records attempt count and error on API failure', () async { final r = _makeRepos(httpClient: mockFlush(500)); await seedChange(r.db, r.accounts); await r.emails.flushPendingChanges('jmap-1', 'pw'); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(1)); expect(changes.first.attempts, 1); expect(changes.first.lastError, isNotNull); }); test('passes ifInState when sync_state exists', () 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': {}, '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', 'updated': {}}, '0', ], ], }), 200, ); }); final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', state: 'est1', syncedAt: DateTime.now(), ), ); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', resourceId: 'jmap-1:e1', changeType: 'flag_seen', payload: '{"seen":true}', createdAt: DateTime.now(), ), ); await r.emails.flushPendingChanges('jmap-1', 'pw'); final firstCall = (capturedBody['methodCalls'] as List).first as List; final args = firstCall[1] as Map; expect(args['ifInState'], 'est1'); // newState returned by server should update our checkpoint final states = await r.db.select(r.db.syncStates).get(); expect(states.first.state, 'est2'); }); test( 'stateMismatch clears sync state and marks change as failed', () async { 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': {}, 'username': 'alice@example.com', 'state': 'sess1', }), 200, ); } // Server responds with stateMismatch error inside Email/set return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Email/set', {'accountId': 'acct1', 'type': 'stateMismatch'}, '0', ], ], }), 200, ); }); final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', state: 'est1', syncedAt: DateTime.now(), ), ); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', resourceId: 'jmap-1:e1', changeType: 'flag_seen', payload: '{"seen":true}', createdAt: DateTime.now(), ), ); await r.emails.flushPendingChanges('jmap-1', 'pw'); // Sync state should be cleared so next cycle does a full re-sync expect(await r.db.select(r.db.syncStates).get(), isEmpty); // Change should still be present but with attempt count bumped final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes.first.attempts, 1); }, ); test( 'discards change immediately on notUpdated (permanent error)', () async { 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': {}, 'username': 'alice@example.com', 'state': 'sess1', }), 200, ); } // Server responds with notUpdated — permanent per-item error return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Email/set', { 'accountId': 'acct1', 'notUpdated': { 'e1': {'type': 'notFound'}, }, }, '0', ], ], }), 200, ); }); final r = _makeRepos(httpClient: client); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', resourceId: 'jmap-1:e1', changeType: 'flag_seen', payload: '{"seen":true}', createdAt: DateTime.now(), ), ); await r.emails.flushPendingChanges('jmap-1', 'pw'); // Permanent error — change is immediately evicted expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }, ); test('evicts change after max attempts (5)', () async { final r = _makeRepos(httpClient: mockFlush(500)); await r.accounts.addAccount(_jmapAccount, 'pw'); // Seed a change already at attempts=4 (one below the eviction threshold) await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'jmap-1', resourceType: 'Email', resourceId: 'jmap-1:e1', changeType: 'flag_seen', payload: '{"seen":true}', createdAt: DateTime.now(), attempts: const Value(4), ), ); await r.emails.flushPendingChanges('jmap-1', 'pw'); // 4+1 = 5 = _maxChangeAttempts → evicted expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }); test( 'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', () async { final List> capturedBodies = []; final client = 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, ); } final body = jsonDecode(req.body) as Map; capturedBodies.add(body); final calls = body['methodCalls'] as List; final methodName = (calls.first as List)[0] as String; if (methodName == 'Mailbox/set') { return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Mailbox/set', { 'accountId': 'acct1', 'created': { 'new-snoozed': {'id': 'mbx-snoozed'}, }, }, '0', ], ], }), 200, ); } return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Email/set', {'accountId': 'acct1', 'updated': {}}, '0', ], ], }), 200, ); }); final r = _makeRepos(httpClient: client); await seedChange( r.db, r.accounts, changeType: 'snooze', payload: jsonEncode({ 'uid': 0, 'src': 'mbx-inbox', 'dest': 'Snoozed', 'until': '2026-05-10T15:00:00.000', }), ); await r.emails.flushPendingChanges('jmap-1', 'pw'); // Change successfully applied — removed from queue. expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); // First API call should be Mailbox/set to create the Snoozed folder. expect(capturedBodies, hasLength(2)); final firstCall = ((capturedBodies.first['methodCalls'] as List).first as List)[0]; expect(firstCall, 'Mailbox/set'); // Second call should be Email/set using the newly created mailbox ID. final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first as List)[1] as Map; final update = (secondCallArgs['update'] as Map)['e1'] as Map; expect(update['mailboxIds/mbx-snoozed'], true); }, ); test( 'snooze uses existing mailbox ID when dest is already a JMAP ID', () async { final r = _makeRepos(httpClient: mockFlush(200)); await seedChange( r.db, r.accounts, changeType: 'snooze', payload: jsonEncode({ 'uid': 0, 'src': 'mbx-inbox', 'dest': 'mbx-snoozed', 'until': '2026-05-10T15:00:00.000', }), ); await r.emails.flushPendingChanges('jmap-1', 'pw'); // Change applied without needing Mailbox/set (dest was already a valid ID). expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); }, ); }); group('JMAP syncEmails body caching', () { Map jmapEmailWithBody({ required String id, required String mailboxId, String? textContent, String? htmlContent, }) => { ..._jmapEmail(id: id, mailboxId: mailboxId), 'textBody': [ if (textContent != null) {'partId': 'text1', 'type': 'text/plain'}, ], 'htmlBody': [ if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'}, ], 'bodyValues': { if (textContent != null) 'text1': { 'value': textContent, 'isEncodingProblem': false, 'isTruncated': false, }, if (htmlContent != null) 'html1': { 'value': htmlContent, 'isEncodingProblem': false, 'isTruncated': false, }, }, 'attachments': [], }; test('full sync caches bodies when bodyValues are present', () async { final r = _makeRepos( httpClient: _mockJmapEmails( apiResponses: [ _emailGetResponse( state: 'est1', list: [ jmapEmailWithBody( id: 'e1', mailboxId: 'mbx1', textContent: 'Hello text', htmlContent: '

Hello

', ), ], ), ], ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.emails.syncEmails('jmap-1', 'mbx1'); final bodies = await r.db.select(r.db.emailBodies).get(); expect(bodies, hasLength(1)); expect(bodies.first.textBody, 'Hello text'); expect(bodies.first.htmlBody, '

Hello

'); }); test('full sync does not write body row when bodyValues absent', () async { final r = _makeRepos( httpClient: _mockJmapEmails( apiResponses: [ _emailGetResponse( state: 'est1', list: [_jmapEmail(id: 'e1', mailboxId: 'mbx1')], ), ], ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); await r.emails.syncEmails('jmap-1', 'mbx1'); final bodies = await r.db.select(r.db.emailBodies).get(); 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, ); } // First API call is Identity/get; respond with a single identity. if (req.body.contains('Identity/get')) { return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Identity/get', { 'accountId': 'acct1', 'state': 'id1', 'list': [ {'id': 'identity1', 'email': 'alice@example.com'}, ], }, 'i', ], ], }), apiStatus, ); } if (req.body.contains('Email/set')) { return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Email/set', emailSetResult ?? { 'accountId': 'acct1', 'newState': 'est2', 'created': { 'em1': {'id': 'newEmailId1'}, }, }, '0', ], ], }), apiStatus, ); } return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ '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, ); } if (req.body.contains('Email/set')) { capturedBody = jsonDecode(req.body) as Map; } // First API call is Identity/get; respond with a single identity. if (req.body.contains('Identity/get')) { return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Identity/get', { 'accountId': 'acct1', 'state': 'id1', 'list': [ {'id': 'identity1', 'email': 'alice@example.com'}, ], }, 'i', ], ], }), 200, ); } if (req.body.contains('Email/set')) { return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ 'Email/set', { 'accountId': 'acct1', 'newState': 'est2', 'created': { 'em1': {'id': 'newId'}, }, }, '0', ], ], }), 200, ); } return http.Response( jsonEncode({ 'sessionState': 's1', 'methodResponses': [ [ '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}); }); }); group('JMAP watchJmapPush', () { // A custom BaseClient that serves session JSON for well-known requests // and an SSE stream for all other GET requests. http.Client makeSseClient({ String? eventSourceUrl, Stream>? sseStream, }) { return _SseTestClient( eventSourceUrl: eventSourceUrl, sseStream: sseStream ?? const Stream.empty(), ); } test('returns empty stream when server has no eventSourceUrl', () async { final r = _makeRepos(httpClient: makeSseClient()); await r.accounts.addAccount(_jmapAccount, 'pw'); final events = await r.emails.watchJmapPush('jmap-1', 'pw').toList(); expect(events, isEmpty); }); test('yields on StateChange event', () async { final sseController = StreamController>(); final r = _makeRepos( httpClient: makeSseClient( eventSourceUrl: 'https://jmap.example.com/events/', sseStream: sseController.stream, ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); final emitted = []; final sub = r.emails.watchJmapPush('jmap-1', 'pw').listen(emitted.add); // Push a StateChange event const event = 'data: {"@type":"StateChange","changed":{}}\n\n'; sseController.add(utf8.encode(event)); await Future.delayed(const Duration(milliseconds: 50)); expect(emitted, hasLength(1)); await sub.cancel(); await sseController.close(); }); test('ignores non-StateChange SSE data lines', () async { final sseController = StreamController>(); final r = _makeRepos( httpClient: makeSseClient( eventSourceUrl: 'https://jmap.example.com/events/', sseStream: sseController.stream, ), ); await r.accounts.addAccount(_jmapAccount, 'pw'); final emitted = []; final sub = r.emails.watchJmapPush('jmap-1', 'pw').listen(emitted.add); const keepalive = ': keepalive\n\n'; const other = 'data: {"@type":"Something"}\n\n'; sseController.add(utf8.encode(keepalive + other)); await Future.delayed(const Duration(milliseconds: 50)); expect(emitted, isEmpty); await sub.cancel(); await sseController.close(); }); }); // ── CONDSTORE tests ────────────────────────────────────────────────────────── // ── Blob expiry (TTL) tests ─────────────────────────────────────────────────── group('blob expiry', () { test('returns cached body when cachedAt is recent', () async { // Uses _makeRepos (IMAP throws if called) — passing without error proves // no IMAP connection was made. final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emailBodies).insertOnConflictUpdate( EmailBodiesCompanion.insert( emailId: 'acc-1:1', textBody: const Value('cached text'), cachedAt: Value(DateTime.now()), ), ); final body = await r.emails.getEmailBody('acc-1:1'); expect(body.textBody, 'cached text'); }); }); // ── Failed mutations tests ──────────────────────────────────────────────────── group('failed mutations', () { test('observeFailedMutations emits only rows with lastError set', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', resourceId: 'acc-1:10', changeType: 'flag_seen', payload: '{"seen":true}', createdAt: DateTime.now(), lastError: const Value('network error'), ), ); await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', resourceId: 'acc-1:11', changeType: 'move', payload: '{"dest":"Archive"}', createdAt: DateTime.now(), // lastError not set → pending, not failed ), ); final mutations = await r.emails.observeFailedMutations('acc-1').first; expect(mutations, hasLength(1)); expect(mutations.first.resourceId, 'acc-1:10'); expect(mutations.first.changeType, 'flag_seen'); expect(mutations.first.lastError, 'network error'); }); test('discardMutation removes the row', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); final rowId = await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', resourceId: 'acc-1:10', changeType: 'delete', payload: '{}', createdAt: DateTime.now(), attempts: const Value(3), lastError: const Value('timeout'), ), ); await r.emails.discardMutation(rowId); final rows = await r.db.select(r.db.pendingChanges).get(); expect(rows, isEmpty); }); test('retryMutation resets attempts and clears lastError', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); final rowId = await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert( accountId: 'acc-1', resourceType: 'email', resourceId: 'acc-1:10', changeType: 'move', payload: '{"dest":"Trash"}', createdAt: DateTime.now(), attempts: const Value(5), lastError: const Value('connection refused'), ), ); await r.emails.retryMutation(rowId); final row = (await r.db.select(r.db.pendingChanges).get()).first; expect(row.attempts, 0); expect(row.lastError, isNull); }); }); group('concurrent moves', () { test( 'two simultaneous moves enqueue two changes and leave email in last destination', () async { final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:5', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 5, receivedAt: DateTime(2024), ), ); // Fire both moves without awaiting to exercise concurrent enqueue logic. final f1 = r.emails.moveEmail('acc-1:5', 'Archive'); final f2 = r.emails.moveEmail('acc-1:5', 'Trash'); await Future.wait([f1, f2]); final changes = await r.db.select(r.db.pendingChanges).get(); expect(changes, hasLength(2)); expect(changes.map((c) => c.changeType), everyElement('move')); final destinations = changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet(); expect(destinations, containsAll(['Archive', 'Trash'])); final email = await r.emails.getEmail('acc-1:5'); expect( email!.mailboxPath, anyOf('Archive', 'Trash'), reason: 'email must be optimistically moved to one of the two destinations', ); }, ); }); group('IMAP SMTP auth failure', () { test('sendEmail propagates SMTP authentication error', () async { final r = _makeRepos( smtpConnect: (Account _, String __, String ___) => Future.error( Exception('535 5.7.8 Authentication credentials invalid'), ), ); await r.accounts.addAccount(_account, 'pw'); const draft = EmailDraft( from: EmailAddress(name: 'Alice', email: 'alice@example.com'), to: [EmailAddress(name: 'Bob', email: 'bob@example.com')], cc: [], subject: 'Test', body: 'Body', ); await expectLater( r.emails.sendEmail('acc-1', draft), throwsA( isA().having( (e) => e.toString(), 'message', contains('535'), ), ), ); }); }); group('IMAP UID validity change', () { test('full re-sync wipes stale emails when uidValidity changes', () async { final r = _makeRepos( imapConnect: (Account _, String __, String ___) async => _FakeImapClientUidValidity(456), ); await r.accounts.addAccount(_account, 'pw'); // Pre-seed two emails from the old server epoch (uidValidity=123). await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:1', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 1, receivedAt: DateTime(2024), ), ); await r.db.into(r.db.emails).insert( EmailsCompanion.insert( id: 'acc-1:2', accountId: 'acc-1', mailboxPath: 'INBOX', uid: 2, receivedAt: DateTime(2024), ), ); // Seed an IMAP checkpoint with the old uidValidity so the code detects // a mismatch and triggers a full re-sync. await r.db.into(r.db.syncStates).insertOnConflictUpdate( SyncStatesCompanion.insert( accountId: 'acc-1', resourceType: 'IMAP:INBOX', state: '{"uidValidity":123,"lastUid":2,"highestModSeq":null}', syncedAt: DateTime(2024), ), ); await r.emails.syncEmails('acc-1', 'INBOX'); // Old emails must be wiped; the fake server returns zero messages. final remaining = await r.db.select(r.db.emails).get(); expect(remaining, isEmpty); // Checkpoint must be updated to the new uidValidity. final stateRow = await (r.db.select(r.db.syncStates) ..where( (t) => t.accountId.equals('acc-1') & t.resourceType.equals('IMAP:INBOX'), )) .getSingleOrNull(); expect(stateRow, isNotNull); final state = jsonDecode(stateRow!.state) as Map; expect(state['uidValidity'], 456); }); }); } // ── Additional fake IMAP client for UID-validity tests ─────────────────────── class _FakeImapClientUidValidity extends FakeImapClient { _FakeImapClientUidValidity(this._uidValidity); final int _uidValidity; @override Future selectMailboxByPath( String path, { bool enableCondStore = false, imap.QResyncParameters? qresync, }) async => imap.Mailbox( encodedName: path, encodedPath: path, flags: [], pathSeparator: '/', uidValidity: _uidValidity, ); @override Future uidSearchMessages({ String searchCriteria = 'ALL', List? returnOptions, Duration? responseTimeout, }) async => imap.SearchImapResult(); } // ── SSE test helper ────────────────────────────────────────────────────────── class _SseTestClient extends http.BaseClient { _SseTestClient({required this.eventSourceUrl, required this.sseStream}); final String? eventSourceUrl; final Stream> sseStream; @override Future send(http.BaseRequest request) async { if (request.url.path.contains('well-known')) { final session = 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': {}, }, 'username': 'alice@example.com', 'state': 'sess1', if (eventSourceUrl != null) 'eventSourceUrl': eventSourceUrl, }); return http.StreamedResponse(Stream.value(utf8.encode(session)), 200); } if (request.headers['Accept'] == 'text/event-stream') { return http.StreamedResponse(sseStream, 200); } return http.StreamedResponse(Stream.value(utf8.encode('{}')), 200); } }