The multi-line python3 -c block had unindented Python code that terminated the YAML block scalar, causing a preExecutionError on every CI run. Inline the Python as a one-liner. Also apply dart format to email_repository_impl.dart and email_repository_impl_test.dart (reformatted by CI container). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2657 lines
86 KiB
Dart
2657 lines
86 KiB
Dart
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<Map<String, dynamic>> 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<String, dynamic> _emailGetResponse({
|
|
required String state,
|
|
required List<Map<String, dynamic>> 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<String, dynamic> _emailChangesResponse({
|
|
required String oldState,
|
|
required String newState,
|
|
List<String> created = const [],
|
|
List<String> updated = const [],
|
|
List<String> destroyed = const [],
|
|
}) =>
|
|
{
|
|
'sessionState': 'sess1',
|
|
'methodResponses': [
|
|
[
|
|
'Email/changes',
|
|
{
|
|
'accountId': 'acct1',
|
|
'oldState': oldState,
|
|
'newState': newState,
|
|
'hasMoreChanges': false,
|
|
'created': created,
|
|
'updated': updated,
|
|
'destroyed': destroyed,
|
|
},
|
|
'0',
|
|
],
|
|
],
|
|
};
|
|
|
|
Map<String, dynamic> _emailGetOnly({
|
|
required String state,
|
|
required List<Map<String, dynamic>> list,
|
|
}) =>
|
|
{
|
|
'sessionState': 'sess1',
|
|
'methodResponses': [
|
|
[
|
|
'Email/get',
|
|
{'accountId': 'acct1', 'state': state, 'list': list},
|
|
'1',
|
|
],
|
|
],
|
|
};
|
|
|
|
Map<String, dynamic> _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} : <String, dynamic>{},
|
|
'hasAttachment': false,
|
|
'preview': 'Hello world',
|
|
'threadId': threadId,
|
|
};
|
|
|
|
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
|
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
|
|
|
|
Future<imap.SmtpClient> _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<imap.ImapClient> Function(Account, String, String)? imapConnect,
|
|
Future<imap.SmtpClient> 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<UnsupportedError>()),
|
|
);
|
|
});
|
|
|
|
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<UnsupportedError>()),
|
|
);
|
|
});
|
|
|
|
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('<p>Hello</p>'),
|
|
cachedAt: Value(DateTime.now()),
|
|
),
|
|
);
|
|
|
|
final body = await r.emails.getEmailBody('acc-1:1');
|
|
expect(body.textBody, 'Hello');
|
|
expect(body.htmlBody, '<p>Hello</p>');
|
|
});
|
|
|
|
// ── 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('searchEmailsGlobal includes emails matched by note text', () async {
|
|
final r = _makeRepos();
|
|
await r.accounts.addAccount(_account, 'pw');
|
|
|
|
// Email whose subject does NOT match — but its note does.
|
|
await r.db.into(r.db.emails).insert(
|
|
EmailsCompanion.insert(
|
|
id: 'acc-1:1',
|
|
accountId: 'acc-1',
|
|
mailboxPath: 'INBOX',
|
|
uid: 1,
|
|
messageId: const Value('<msg1@example.com>'),
|
|
subject: const Value('Weekly report'),
|
|
receivedAt: DateTime(2024),
|
|
),
|
|
);
|
|
// Add a note referencing the email's messageId.
|
|
await r.db.into(r.db.emailNotes).insert(
|
|
EmailNotesCompanion.insert(
|
|
id: 'note-1',
|
|
accountId: 'acc-1',
|
|
messageId: '<msg1@example.com>',
|
|
noteText: 'Urgent follow-up needed',
|
|
serverId: '42',
|
|
createdAt: DateTime(2024),
|
|
),
|
|
);
|
|
|
|
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
|
|
expect(results, hasLength(1));
|
|
expect(results.first.subject, 'Weekly report');
|
|
});
|
|
|
|
test('searchEmails includes emails matched by note text in mailbox',
|
|
() 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,
|
|
messageId: const Value('<msg1@example.com>'),
|
|
subject: const Value('Project update'),
|
|
receivedAt: DateTime(2024),
|
|
),
|
|
);
|
|
// Email in a different mailbox — its note must not appear in INBOX search.
|
|
await r.db.into(r.db.emails).insert(
|
|
EmailsCompanion.insert(
|
|
id: 'acc-1:2',
|
|
accountId: 'acc-1',
|
|
mailboxPath: 'Sent',
|
|
uid: 2,
|
|
messageId: const Value('<msg2@example.com>'),
|
|
subject: const Value('Other email'),
|
|
receivedAt: DateTime(2024),
|
|
),
|
|
);
|
|
await r.db.into(r.db.emailNotes).insert(
|
|
EmailNotesCompanion.insert(
|
|
id: 'note-1',
|
|
accountId: 'acc-1',
|
|
messageId: '<msg1@example.com>',
|
|
noteText: 'remember to call client',
|
|
serverId: '42',
|
|
createdAt: DateTime(2024),
|
|
),
|
|
);
|
|
await r.db.into(r.db.emailNotes).insert(
|
|
EmailNotesCompanion.insert(
|
|
id: 'note-2',
|
|
accountId: 'acc-1',
|
|
messageId: '<msg2@example.com>',
|
|
noteText: 'remember to call client',
|
|
serverId: '43',
|
|
createdAt: DateTime(2024),
|
|
),
|
|
);
|
|
|
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
|
|
expect(results, hasLength(1));
|
|
expect(results.first.subject, 'Project update');
|
|
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 = '<p>Hello from JMAP</p>',
|
|
}) =>
|
|
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, '<p>Hello from JMAP</p>');
|
|
|
|
// 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': <String, dynamic>{},
|
|
'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<void> 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<void> 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<String, dynamic> 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<String, dynamic>;
|
|
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<dynamic>).first as List<dynamic>;
|
|
final args = firstCall[1] as Map<String, dynamic>;
|
|
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<Map<String, dynamic>> 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<String, dynamic>;
|
|
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<String, dynamic>;
|
|
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
|
as Map<String, dynamic>;
|
|
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<String, dynamic> 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: '<p>Hello</p>',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
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, '<p>Hello</p>');
|
|
});
|
|
|
|
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<String, dynamic>? emailSetResult,
|
|
Map<String, dynamic>? 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<JmapException>()),
|
|
);
|
|
});
|
|
|
|
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<JmapException>()),
|
|
);
|
|
});
|
|
|
|
test('uses Sent mailbox ID when role=sent mailbox exists in DB', () async {
|
|
late Map<String, dynamic> 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<String, dynamic>;
|
|
}
|
|
// 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<dynamic>;
|
|
final emailSetArgs =
|
|
(calls.first as List<dynamic>)[1] as Map<String, dynamic>;
|
|
final createMap = emailSetArgs['create'] as Map<String, dynamic>;
|
|
final em1Create = createMap['em1'] as Map<String, dynamic>;
|
|
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<List<int>>? 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<List<int>>();
|
|
final r = _makeRepos(
|
|
httpClient: makeSseClient(
|
|
eventSourceUrl: 'https://jmap.example.com/events/',
|
|
sseStream: sseController.stream,
|
|
),
|
|
);
|
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
|
|
final emitted = <void>[];
|
|
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<List<int>>();
|
|
final r = _makeRepos(
|
|
httpClient: makeSseClient(
|
|
eventSourceUrl: 'https://jmap.example.com/events/',
|
|
sseStream: sseController.stream,
|
|
),
|
|
);
|
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
|
|
final emitted = <void>[];
|
|
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<Exception>().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<String, dynamic>;
|
|
expect(state['uidValidity'], 456);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Additional fake IMAP client for UID-validity tests ───────────────────────
|
|
|
|
class _FakeImapClientUidValidity extends FakeImapClient {
|
|
_FakeImapClientUidValidity(this._uidValidity);
|
|
final int _uidValidity;
|
|
|
|
@override
|
|
Future<imap.Mailbox> selectMailboxByPath(
|
|
String path, {
|
|
bool enableCondStore = false,
|
|
imap.QResyncParameters? qresync,
|
|
}) async =>
|
|
imap.Mailbox(
|
|
encodedName: path,
|
|
encodedPath: path,
|
|
flags: [],
|
|
pathSeparator: '/',
|
|
uidValidity: _uidValidity,
|
|
);
|
|
|
|
@override
|
|
Future<imap.SearchImapResult> uidSearchMessages({
|
|
String searchCriteria = 'ALL',
|
|
List<imap.ReturnOption>? 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<List<int>> sseStream;
|
|
|
|
@override
|
|
Future<http.StreamedResponse> 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);
|
|
}
|
|
}
|