fix: format, analyze-fix and update mocks

This commit is contained in:
Thomas Güttler
2026-06-02 17:10:16 +02:00
parent 3520f161e3
commit 8ea8d71f42
84 changed files with 1972 additions and 2201 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container { func (m *Ci) toolchain() *dagger.Container {
return dag.Container(). return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6"). From("ghcr.io/cirruslabs/flutter:3.44.0").
WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
+4 -4
View File
@@ -346,10 +346,10 @@ class SyncEmailsResult {
); );
SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult( SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult(
fetched: fetched + other.fetched, fetched: fetched + other.fetched,
skipped: skipped + other.skipped, skipped: skipped + other.skipped,
bytesTransferred: bytesTransferred + other.bytesTransferred, bytesTransferred: bytesTransferred + other.bytesTransferred,
); );
} }
class ReliabilityResult { class ReliabilityResult {
@@ -35,9 +35,8 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
try { try {
final url = Uri.https(domain, '/.well-known/jmap'); final url = Uri.https(domain, '/.well-known/jmap');
final request = http.Request('GET', url)..followRedirects = false; final request = http.Request('GET', url)..followRedirects = false;
final streamed = await _client final streamed =
.send(request) await _client.send(request).timeout(const Duration(seconds: 5));
.timeout(const Duration(seconds: 5));
String sessionUrl; String sessionUrl;
if (streamed.statusCode >= 300 && streamed.statusCode < 400) { if (streamed.statusCode >= 300 && streamed.statusCode < 400) {
+26 -17
View File
@@ -6,24 +6,30 @@ import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart'; import 'package:sharedinbox/data/imap/managesieve_client.dart';
typedef ImapConnectForTestFn = typedef ImapConnectForTestFn = Future<imap.ImapClient> Function(
Future<imap.ImapClient> Function(Account, String username, String password); Account,
String username,
String password,
);
typedef SmtpConnectForTestFn = typedef SmtpConnectForTestFn = Future<imap.SmtpClient> Function(
Future<imap.SmtpClient> Function(Account, String username, String password); Account,
String username,
String password,
);
typedef ManageSieveConnectForTestFn = typedef ManageSieveConnectForTestFn = Future<ManageSieveClient> Function({
Future<ManageSieveClient> Function({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, });
});
Future<ManageSieveClient> _defaultManageSieveConnect({ Future<ManageSieveClient> _defaultManageSieveConnect({
required String host, required String host,
required int port, required int port,
required bool useTls, required bool useTls,
}) => ManageSieveClient.connect(host: host, port: port, useTls: useTls); }) =>
ManageSieveClient.connect(host: host, port: port, useTls: useTls);
abstract class ConnectionTestService { abstract class ConnectionTestService {
/// Verifies credentials and returns the effective username used. /// Verifies credentials and returns the effective username used.
@@ -37,9 +43,9 @@ class ConnectionTestServiceImpl implements ConnectionTestService {
ImapConnectForTestFn imapConnect = connectImap, ImapConnectForTestFn imapConnect = connectImap,
SmtpConnectForTestFn smtpConnect = connectSmtp, SmtpConnectForTestFn smtpConnect = connectSmtp,
ManageSieveConnectForTestFn manageSieveConnect = _defaultManageSieveConnect, ManageSieveConnectForTestFn manageSieveConnect = _defaultManageSieveConnect,
}) : _imapConnect = imapConnect, }) : _imapConnect = imapConnect,
_smtpConnect = smtpConnect, _smtpConnect = smtpConnect,
_manageSieveConnect = manageSieveConnect; _manageSieveConnect = manageSieveConnect;
final http.Client _httpClient; final http.Client _httpClient;
final ImapConnectForTestFn _imapConnect; final ImapConnectForTestFn _imapConnect;
@@ -156,9 +162,12 @@ class ConnectionTestServiceImpl implements ConnectionTestService {
for (final username in candidates) { for (final username in candidates) {
try { try {
final credentials = base64.encode(utf8.encode('$username:$password')); final credentials = base64.encode(utf8.encode('$username:$password'));
final resp = await _httpClient final resp = await _httpClient.get(
.get(sessionUri, headers: {'Authorization': 'Basic $credentials'}) sessionUri,
.timeout(const Duration(seconds: 10)); headers: {
'Authorization': 'Basic $credentials',
},
).timeout(const Duration(seconds: 10));
if (resp.statusCode == 401 || resp.statusCode == 403) { if (resp.statusCode == 401 || resp.statusCode == 403) {
lastError = Exception( lastError = Exception(
'Authentication failed: wrong username or password', 'Authentication failed: wrong username or password',
@@ -4,12 +4,11 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart'; import 'package:sharedinbox/data/imap/managesieve_client.dart';
/// Returns true if the endpoint accepts a ManageSieve handshake. /// Returns true if the endpoint accepts a ManageSieve handshake.
typedef ManageSieveProbeFn = typedef ManageSieveProbeFn = Future<bool> Function({
Future<bool> Function({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, });
});
Future<bool> _defaultManageSieveProbe({ Future<bool> _defaultManageSieveProbe({
required String host, required String host,
@@ -66,22 +65,22 @@ class ManageSieveProbeService {
} }
Account _withAvailability(Account a, bool available) => Account( Account _withAvailability(Account a, bool available) => Account(
id: a.id, id: a.id,
displayName: a.displayName, displayName: a.displayName,
email: a.email, email: a.email,
username: a.username, username: a.username,
type: a.type, type: a.type,
imapHost: a.imapHost, imapHost: a.imapHost,
imapPort: a.imapPort, imapPort: a.imapPort,
imapSsl: a.imapSsl, imapSsl: a.imapSsl,
smtpHost: a.smtpHost, smtpHost: a.smtpHost,
smtpPort: a.smtpPort, smtpPort: a.smtpPort,
smtpSsl: a.smtpSsl, smtpSsl: a.smtpSsl,
manageSieveHost: a.manageSieveHost, manageSieveHost: a.manageSieveHost,
manageSievePort: a.manageSievePort, manageSievePort: a.manageSievePort,
manageSieveSsl: a.manageSieveSsl, manageSieveSsl: a.manageSieveSsl,
manageSieveAvailable: available, manageSieveAvailable: available,
jmapUrl: a.jmapUrl, jmapUrl: a.jmapUrl,
verbose: a.verbose, verbose: a.verbose,
); );
} }
+1 -2
View File
@@ -18,8 +18,7 @@ Future<void> initNotifications() async {
); );
await _plugin await _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin AndroidFlutterLocalNotificationsPlugin>()
>()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
_initialized = true; _initialized = true;
} on MissingPluginException { } on MissingPluginException {
+11 -12
View File
@@ -166,18 +166,17 @@ class ShareEncryptionService {
final cipherBytes = Uint8List.fromList(box.cipherText); final cipherBytes = Uint8List.fromList(box.cipherText);
final macBytes = Uint8List.fromList(box.mac.bytes); final macBytes = Uint8List.fromList(box.mac.bytes);
final out = final out = Uint8List(
Uint8List( _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen, )
) ..setAll(0, recipientKeyId)
..setAll(0, recipientKeyId) ..setAll(_keyIdLen, ephPubBytes)
..setAll(_keyIdLen, ephPubBytes) ..setAll(_keyIdLen + _pubKeyLen, nonce)
..setAll(_keyIdLen + _pubKeyLen, nonce) ..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes) ..setAll(
..setAll( _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length, macBytes,
macBytes, );
);
return '$_encAccountsPrefix${base64.encode(out)}'; return '$_encAccountsPrefix${base64.encode(out)}';
} }
+1 -2
View File
@@ -62,8 +62,7 @@ class UndoService extends Notifier<List<UndoAction>> {
for (final id in action.emailIds) { for (final id in action.emailIds) {
// 1. Try to cancel the original change (if not started yet). // 1. Try to cancel the original change (if not started yet).
final cancelled = final cancelled = await repo.cancelPendingChange(id, 'delete') ||
await repo.cancelPendingChange(id, 'delete') ||
await repo.cancelPendingChange(id, 'move') || await repo.cancelPendingChange(id, 'move') ||
await repo.cancelPendingChange(id, 'snooze'); await repo.cancelPendingChange(id, 'snooze');
+2 -2
View File
@@ -21,8 +21,8 @@ final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
final platformKey = Platform.isLinux final platformKey = Platform.isLinux
? 'linux' ? 'linux'
: Platform.isWindows : Platform.isWindows
? 'windows' ? 'windows'
: null; : null;
if (platformKey == null || _kAppVersion.isEmpty) return null; if (platformKey == null || _kAppVersion.isEmpty) return null;
try { try {
+2 -3
View File
@@ -64,9 +64,8 @@ class SieveInterpreter {
return switch (rule.joinType) { return switch (rule.joinType) {
'allof' => rule.conditions.every((c) => _evalCondition(c, email)), 'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)), 'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
_ => _ => rule.conditions.length == 1 &&
rule.conditions.length == 1 && _evalCondition(rule.conditions.first, email),
_evalCondition(rule.conditions.first, email),
}; };
} }
+2 -2
View File
@@ -421,8 +421,8 @@ class _Scanner {
if (_isWordChar(ch)) { if (_isWordChar(ch)) {
final start = _pos; final start = _pos;
var end = _pos + 1; var end = _pos + 1;
while (end < _src.length && while (
(_isWordChar(_src[end]) || _src[end] == ':')) { end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
// Include trailing colon for "text:" multiline token. // Include trailing colon for "text:" multiline token.
if (_src[end] == ':') { if (_src[end] == ':') {
end++; end++;
+64 -68
View File
@@ -29,10 +29,10 @@ class AccountSyncManager {
SyncLogRepository syncLog = const NoOpSyncLogRepository(), SyncLogRepository syncLog = const NoOpSyncLogRepository(),
DraftRepository? drafts, DraftRepository? drafts,
OnNewMailCallback? onNewMail, OnNewMailCallback? onNewMail,
}) : _imapConnect = imapConnect, }) : _imapConnect = imapConnect,
_syncLog = syncLog, _syncLog = syncLog,
_drafts = drafts, _drafts = drafts,
_onNewMail = onNewMail; _onNewMail = onNewMail;
final AccountRepository _accounts; final AccountRepository _accounts;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
@@ -69,26 +69,26 @@ class AccountSyncManager {
final id = account.id; final id = account.id;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
_accounts, _accounts,
_mailboxes, _mailboxes,
_emails, _emails,
_imapConnect, _imapConnect,
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true), onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false), onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
_mailboxes, _mailboxes,
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true), onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false), onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
}; };
_active[account.id] = loop; _active[account.id] = loop;
loop.start(); loop.start();
@@ -129,33 +129,33 @@ class AccountSyncManager {
final accounts = await _accounts.observeAccounts().first; final accounts = await _accounts.observeAccounts().first;
final account = accounts.cast<Account?>().firstWhere( final account = accounts.cast<Account?>().firstWhere(
(a) => a?.id == accountId, (a) => a?.id == accountId,
orElse: () => null, orElse: () => null,
); );
if (account == null) return; if (account == null) return;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
_accounts, _accounts,
_mailboxes, _mailboxes,
_emails, _emails,
_imapConnect, _imapConnect,
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true), onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false), onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
_mailboxes, _mailboxes,
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true), onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false), onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
}; };
_active[accountId] = loop; _active[accountId] = loop;
loop.start(); loop.start();
@@ -184,8 +184,8 @@ class _AccountSync implements _SyncLoop {
this._onNewMail, { this._onNewMail, {
void Function()? onSyncStart, void Function()? onSyncStart,
void Function()? onSyncEnd, void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart, }) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd; _onSyncEnd = onSyncEnd;
final Account account; final Account account;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -379,9 +379,8 @@ class _AccountSync implements _SyncLoop {
if (!_running) return; if (!_running) return;
_stopSignal = Completer<void>(); _stopSignal = Completer<void>();
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
final username = account.username.isNotEmpty final username =
? account.username account.username.isNotEmpty ? account.username : account.email;
: account.email;
final client = await _imapConnect(account, username, password); final client = await _imapConnect(account, username, password);
_idleClient = client; _idleClient = client;
try { try {
@@ -397,13 +396,12 @@ class _AccountSync implements _SyncLoop {
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent, e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
) )
.listen((e) { .listen((e) {
if (e is imap.ImapMessagesExistEvent && if (e is imap.ImapMessagesExistEvent &&
e.newMessagesExists > e.oldMessagesExists) { e.newMessagesExists > e.oldMessagesExists) {
hasNewMail = true; hasNewMail = true;
} }
if (!newMessageCompleter.isCompleted) if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
newMessageCompleter.complete(); });
});
await client.idleStart(); await client.idleStart();
@@ -445,8 +443,8 @@ class _JmapAccountSync implements _SyncLoop {
this._syncLog, { this._syncLog, {
void Function()? onSyncStart, void Function()? onSyncStart,
void Function()? onSyncEnd, void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart, }) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd; _onSyncEnd = onSyncEnd;
final Account account; final Account account;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
@@ -642,15 +640,13 @@ class _JmapAccountSync implements _SyncLoop {
// Try JMAP push (RFC 8887 EventSource). Falls back to poll timer when // Try JMAP push (RFC 8887 EventSource). Falls back to poll timer when
// the server doesn't advertise an eventSourceUrl or the connection fails. // the server doesn't advertise an eventSourceUrl or the connection fails.
final pushReady = Completer<void>(); final pushReady = Completer<void>();
final pushSub = _emails final pushSub = _emails.watchJmapPush(account.id, password).listen(
.watchJmapPush(account.id, password) (_) {
.listen( if (!pushReady.isCompleted) pushReady.complete();
(_) { },
if (!pushReady.isCompleted) pushReady.complete(); onDone: () {},
}, onError: (_) {},
onDone: () {}, );
onError: (_) {},
);
final pollTimer = Timer(_pollInterval, () { final pollTimer = Timer(_pollInterval, () {
if (_stopSignal != null && !_stopSignal!.isCompleted) { if (_stopSignal != null && !_stopSignal!.isCompleted) {
+10 -13
View File
@@ -83,9 +83,8 @@ Future<void> _checkAccount(
) async { ) async {
try { try {
final password = await accountRepo.getPassword(account.id); final password = await accountRepo.getPassword(account.id);
final username = account.username.isNotEmpty final username =
? account.username account.username.isNotEmpty ? account.username : account.email;
: account.email;
final client = await connectImap(account, username, password); final client = await connectImap(account, username, password);
try { try {
final status = await client.statusMailbox( final status = await client.statusMailbox(
@@ -94,18 +93,16 @@ Future<void> _checkAccount(
); );
final currentUidNext = status.uidNext; final currentUidNext = status.uidNext;
final stored = final stored = await (db.select(db.syncStates)
await (db.select(db.syncStates)..where( ..where(
(t) => (t) =>
t.accountId.equals(account.id) & t.accountId.equals(account.id) &
t.resourceType.equals(_kResourceType), t.resourceType.equals(_kResourceType),
)) ))
.getSingleOrNull(); .getSingleOrNull();
final lastUidNext = _parseUidNext(stored?.state); final lastUidNext = _parseUidNext(stored?.state);
await db await db.into(db.syncStates).insertOnConflictUpdate(
.into(db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: account.id, accountId: account.id,
resourceType: _kResourceType, resourceType: _kResourceType,
+2 -5
View File
@@ -76,14 +76,11 @@ class ReliabilityRunner {
} }
} }
final isHealthy = final isHealthy = totalMissingLocally == 0 &&
totalMissingLocally == 0 &&
totalMissingOnServer == 0 && totalMissingOnServer == 0 &&
totalFlagMismatches == 0; totalFlagMismatches == 0;
await _db await _db.into(_db.syncHealth).insertOnConflictUpdate(
.into(_db.syncHealth)
.insertOnConflictUpdate(
SyncHealthCompanion.insert( SyncHealthCompanion.insert(
accountId: accountId, accountId: accountId,
lastVerifiedAt: DateTime.now(), lastVerifiedAt: DateTime.now(),
+218 -216
View File
@@ -388,228 +388,231 @@ class AppDatabase extends _$AppDatabase {
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async { onCreate: (m) async {
await m.createAll(); await m.createAll();
await _createEmailFts(); await _createEmailFts();
}, },
onUpgrade: (m, from, to) async { onUpgrade: (m, from, to) async {
// NOTE: m.createTable(T) creates the LATEST version of table T. // NOTE: m.createTable(T) creates the LATEST version of table T.
// If you later add a column C to T in version X, you must guard // If you later add a column C to T in version X, you must guard
// addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`. // addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`.
if (from < 2) { if (from < 2) {
await m.addColumn(accounts, accounts.accountType); await m.addColumn(accounts, accounts.accountType);
await m.addColumn(accounts, accounts.jmapUrl); await m.addColumn(accounts, accounts.jmapUrl);
} }
if (from < 3) { if (from < 3) {
await m.addColumn(accounts, accounts.username); await m.addColumn(accounts, accounts.username);
} }
if (from < 4) { if (from < 4) {
await m.createTable(drafts); await m.createTable(drafts);
} }
if (from < 5) { if (from < 5) {
await m.createTable(syncStates); await m.createTable(syncStates);
} }
if (from < 6) { if (from < 6) {
await m.createTable(pendingChanges); await m.createTable(pendingChanges);
} }
if (from < 7) { if (from < 7) {
await m.createTable(syncLogs); await m.createTable(syncLogs);
} }
if (from < 8) { if (from < 8) {
await m.addColumn(mailboxes, mailboxes.role); await m.addColumn(mailboxes, mailboxes.role);
} }
if (from < 9) { if (from < 9) {
await m.addColumn(emailBodies, emailBodies.cachedAt); await m.addColumn(emailBodies, emailBodies.cachedAt);
} }
if (from >= 7 && from < 10) { if (from >= 7 && from < 10) {
await m.addColumn(syncLogs, syncLogs.protocol); await m.addColumn(syncLogs, syncLogs.protocol);
await m.addColumn(syncLogs, syncLogs.mailboxesSynced); await m.addColumn(syncLogs, syncLogs.mailboxesSynced);
await m.addColumn(syncLogs, syncLogs.pendingFlushed); await m.addColumn(syncLogs, syncLogs.pendingFlushed);
} }
if (from >= 7 && from < 11) { if (from >= 7 && from < 11) {
await m.addColumn(syncLogs, syncLogs.emailsSkipped); await m.addColumn(syncLogs, syncLogs.emailsSkipped);
await m.addColumn(syncLogs, syncLogs.bytesTransferred); await m.addColumn(syncLogs, syncLogs.bytesTransferred);
} }
if (from < 12) { if (from < 12) {
await m.createTable(syncLogMailboxes); await m.createTable(syncLogMailboxes);
} }
if (from < 13) { if (from < 13) {
await m.addColumn(accounts, accounts.verbose); await m.addColumn(accounts, accounts.verbose);
if (from >= 7) { if (from >= 7) {
await m.addColumn(syncLogs, syncLogs.protocolLog); await m.addColumn(syncLogs, syncLogs.protocolLog);
} }
} }
if (from < 14) { if (from < 14) {
await m.addColumn(emails, emails.threadId); await m.addColumn(emails, emails.threadId);
await m.addColumn(emails, emails.messageId); await m.addColumn(emails, emails.messageId);
await m.addColumn(emails, emails.inReplyTo); await m.addColumn(emails, emails.inReplyTo);
await m.addColumn(emails, emails.references); await m.addColumn(emails, emails.references);
} }
if (from < 15) { if (from < 15) {
await m.addColumn(accounts, accounts.manageSieveHost); await m.addColumn(accounts, accounts.manageSieveHost);
await m.addColumn(accounts, accounts.manageSievePort); await m.addColumn(accounts, accounts.manageSievePort);
await m.addColumn(accounts, accounts.manageSieveSsl); await m.addColumn(accounts, accounts.manageSieveSsl);
} }
if (from < 16) { if (from < 16) {
await m.addColumn(accounts, accounts.manageSieveAvailable); await m.addColumn(accounts, accounts.manageSieveAvailable);
} }
if (from < 17) { if (from < 17) {
await m.createTable(threads); await m.createTable(threads);
// Populate threads from existing emails. // Populate threads from existing emails.
final allRows = await select(emails).get(); final allRows = await select(emails).get();
final groups = <String, List<Email>>{}; final groups = <String, List<Email>>{};
for (final row in allRows) { for (final row in allRows) {
final key = final key =
'${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}'; '${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}';
groups.putIfAbsent(key, () => []).add(row); groups.putIfAbsent(key, () => []).add(row);
} }
for (final threadEmails in groups.values) { for (final threadEmails in groups.values) {
threadEmails.sort((a, b) { threadEmails.sort((a, b) {
final da = a.sentAt ?? a.receivedAt; final da = a.sentAt ?? a.receivedAt;
final db = b.sentAt ?? b.receivedAt; final db = b.sentAt ?? b.receivedAt;
return da.compareTo(db); return da.compareTo(db);
}); });
final latest = threadEmails.last; final latest = threadEmails.last;
await into(threads).insert( await into(threads).insert(
ThreadsCompanion.insert( ThreadsCompanion.insert(
id: latest.threadId ?? latest.id, id: latest.threadId ?? latest.id,
accountId: latest.accountId, accountId: latest.accountId,
mailboxPath: latest.mailboxPath, mailboxPath: latest.mailboxPath,
subject: Value(latest.subject), subject: Value(latest.subject),
latestDate: latest.sentAt ?? latest.receivedAt, latestDate: latest.sentAt ?? latest.receivedAt,
messageCount: Value(threadEmails.length), messageCount: Value(threadEmails.length),
hasUnread: Value(threadEmails.any((e) => !e.isSeen)), hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
isFlagged: Value(threadEmails.any((e) => e.isFlagged)), isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
preview: Value(latest.preview), preview: Value(latest.preview),
latestEmailId: latest.id, latestEmailId: latest.id,
emailIdsJson: Value( emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()), jsonEncode(threadEmails.map((e) => e.id).toList()),
),
participantsJson: Value(
latest.fromJson,
), // Good enough for migration
),
);
}
}
if (from < 18) {
// Index for sorting email list by date.
await m.createIndex(
Index(
'emails_received_at',
'CREATE INDEX emails_received_at ON emails (account_id, mailbox_path, received_at DESC);',
), ),
participantsJson: Value( );
latest.fromJson, // Index for finding emails in a thread.
), // Good enough for migration await m.createIndex(
), Index(
); 'emails_thread_id',
} 'CREATE INDEX emails_thread_id ON emails (account_id, mailbox_path, thread_id);',
} ),
if (from < 18) { );
// Index for sorting email list by date. // Index for pending changes queue.
await m.createIndex( await m.createIndex(
Index( Index(
'emails_received_at', 'pending_changes_account_id',
'CREATE INDEX emails_received_at ON emails (account_id, mailbox_path, received_at DESC);', 'CREATE INDEX pending_changes_account_id ON pending_changes (account_id);',
), ),
); );
// Index for finding emails in a thread. }
await m.createIndex( if (from < 19) {
Index( await m.createTable(syncHealth);
'emails_thread_id', }
'CREATE INDEX emails_thread_id ON emails (account_id, mailbox_path, thread_id);', if (from < 20) {
), await m.addColumn(emailBodies, emailBodies.headersJson);
); }
// Index for pending changes queue. if (from < 21) {
await m.createIndex( await m.createTable(undoActions);
Index( }
'pending_changes_account_id', if (from < 22) {
'CREATE INDEX pending_changes_account_id ON pending_changes (account_id);', final check = await customSelect('PRAGMA table_info(emails)').get();
), final names = check.map((row) => row.read<String>('name')).toList();
);
}
if (from < 19) {
await m.createTable(syncHealth);
}
if (from < 20) {
await m.addColumn(emailBodies, emailBodies.headersJson);
}
if (from < 21) {
await m.createTable(undoActions);
}
if (from < 22) {
final check = await customSelect('PRAGMA table_info(emails)').get();
final names = check.map((row) => row.read<String>('name')).toList();
if (!names.contains('snoozed_until')) { if (!names.contains('snoozed_until')) {
await m.addColumn(emails, emails.snoozedUntil); await m.addColumn(emails, emails.snoozedUntil);
} }
if (!names.contains('snoozed_from_mailbox_path')) { if (!names.contains('snoozed_from_mailbox_path')) {
await m.addColumn(emails, emails.snoozedFromMailboxPath); await m.addColumn(emails, emails.snoozedFromMailboxPath);
} }
await m.createIndex( await m.createIndex(
Index( Index(
'emails_snoozed_until', 'emails_snoozed_until',
'CREATE INDEX IF NOT EXISTS emails_snoozed_until ON emails (account_id, snoozed_until) WHERE snoozed_until IS NOT NULL;', 'CREATE INDEX IF NOT EXISTS emails_snoozed_until ON emails (account_id, snoozed_until) WHERE snoozed_until IS NOT NULL;',
), ),
); );
} }
if (from < 23) { if (from < 23) {
await m.addColumn(emails, emails.listUnsubscribeHeader); await m.addColumn(emails, emails.listUnsubscribeHeader);
} }
if (from >= 4 && from < 24) { if (from >= 4 && from < 24) {
await m.addColumn(drafts, drafts.imapServerId); await m.addColumn(drafts, drafts.imapServerId);
} }
if (from < 25) { if (from < 25) {
// For observeMailboxes: filter by account_id, sort by path. // For observeMailboxes: filter by account_id, sort by path.
await m.createIndex( await m.createIndex(
Index( Index(
'mailboxes_account_id', 'mailboxes_account_id',
'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);', 'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);',
), ),
); );
// For observeThreads: filter by account_id+mailbox_path, sort by latest_date. // For observeThreads: filter by account_id+mailbox_path, sort by latest_date.
await m.createIndex( await m.createIndex(
Index( Index(
'threads_latest_date', 'threads_latest_date',
'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);', 'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);',
), ),
); );
} }
if (from < 26) { if (from < 26) {
await _createEmailFts(); await _createEmailFts();
// Backfill FTS index from existing rows. // Backfill FTS index from existing rows.
await customStatement(''' await customStatement('''
INSERT INTO email_fts(rowid, subject, preview, from_json) INSERT INTO email_fts(rowid, subject, preview, from_json)
SELECT rowid, subject, preview, from_json FROM emails SELECT rowid, subject, preview, from_json FROM emails
'''); ''');
} }
if (from < 27) { if (from < 27) {
await m.createTable(searchHistoryEntries); await m.createTable(searchHistoryEntries);
} }
if (from < 28) { if (from < 28) {
await m.addColumn(emailBodies, emailBodies.mimeTreeJson); await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
} }
if (from < 29) { if (from < 29) {
await m.createTable(localSieveScripts); await m.createTable(localSieveScripts);
} }
if (from >= 12 && from < 30) { if (from >= 12 && from < 30) {
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs); await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
} }
if (from < 31) { if (from < 31) {
await m.createTable(shareKeys); await m.createTable(shareKeys);
} }
if (from < 32) { if (from < 32) {
await m.createTable(localSieveApplied); await m.createTable(localSieveApplied);
} }
if (from >= 7 && from < 33) { if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace); await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent); await m.addColumn(syncLogs, syncLogs.isPermanent);
} }
if (from < 34) { if (from < 34) {
await m.createTable(userPreferences); await m.createTable(userPreferences);
} }
if (from >= 34 && from < 35) { if (from >= 34 && from < 35) {
await m.addColumn( await m.addColumn(
userPreferences, userPreferences,
userPreferences.mailViewButtonPosition, userPreferences.mailViewButtonPosition,
); );
} }
if (from >= 34 && from < 36) { if (from >= 34 && from < 36) {
await m.addColumn(userPreferences, userPreferences.afterMailViewAction); await m.addColumn(
} userPreferences,
}, userPreferences.afterMailViewAction,
); );
}
},
);
} }
// Resolved once in main() via initDatabasePath() before runApp(). // Resolved once in main() via initDatabasePath() before runApp().
@@ -660,8 +663,7 @@ Future<String> _resolveDatabasePath() async {
} }
throw PlatformException( throw PlatformException(
code: 'channel-error', code: 'channel-error',
message: message: 'path_provider unavailable after ${delays.length + 1} attempts — '
'path_provider unavailable after ${delays.length + 1} attempts — '
'cannot open database.', 'cannot open database.',
); );
} }
+19 -20
View File
@@ -11,7 +11,8 @@ class LocalSieveRepository {
Future<List<SieveScript>> listScripts(String accountId) async { Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select( final rows = await (_db.select(
_db.localSieveScripts, _db.localSieveScripts,
)..where((t) => t.accountId.equals(accountId))).get(); )..where((t) => t.accountId.equals(accountId)))
.get();
return rows return rows
.map( .map(
(r) => SieveScript( (r) => SieveScript(
@@ -26,11 +27,10 @@ class LocalSieveRepository {
Future<String> getScriptContent(String accountId, String blobId) async { Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId); final rowId = int.parse(blobId);
final row = final row = await (_db.select(
await (_db.select( _db.localSieveScripts,
_db.localSieveScripts, )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) .getSingleOrNull();
.getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId'); if (row == null) throw Exception('Local script not found: $blobId');
return row.content; return row.content;
} }
@@ -46,16 +46,16 @@ class LocalSieveRepository {
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
.write( .write(
LocalSieveScriptsCompanion( LocalSieveScriptsCompanion(
name: Value(name), name: Value(name),
content: Value(content), content: Value(content),
), ),
); );
final updated = final updated = await (_db.select(_db.localSieveScripts)
await (_db.select(_db.localSieveScripts)..where( ..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), (t) => t.id.equals(rowId) & t.accountId.equals(accountId),
)) ))
.getSingleOrNull(); .getSingleOrNull();
return SieveScript( return SieveScript(
id: id, id: id,
name: name, name: name,
@@ -63,9 +63,7 @@ class LocalSieveRepository {
isActive: updated?.isActive ?? false, isActive: updated?.isActive ?? false,
); );
} }
final rowId = await _db final rowId = await _db.into(_db.localSieveScripts).insert(
.into(_db.localSieveScripts)
.insert(
LocalSieveScriptsCompanion.insert( LocalSieveScriptsCompanion.insert(
accountId: accountId, accountId: accountId,
name: name, name: name,
@@ -80,7 +78,8 @@ class LocalSieveRepository {
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.delete( await (_db.delete(
_db.localSieveScripts, _db.localSieveScripts,
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))).go(); )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
.go();
} }
Future<void> activateScript(String accountId, String scriptId) async { Future<void> activateScript(String accountId, String scriptId) async {
+7 -9
View File
@@ -6,12 +6,11 @@ import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/utils/host_utils.dart'; import 'package:sharedinbox/core/utils/host_utils.dart';
import 'package:sharedinbox/data/imap/tls_error.dart'; import 'package:sharedinbox/data/imap/tls_error.dart';
typedef ImapConnectFn = typedef ImapConnectFn = Future<ImapClient> Function(
Future<ImapClient> Function( Account account,
Account account, String username,
String username, String password,
String password, );
);
/// Zone value key signalling that a [StringBuffer] for protocol logging is /// Zone value key signalling that a [StringBuffer] for protocol logging is
/// active. When this key is non-null in the current zone, [connectImap] /// active. When this key is non-null in the current zone, [connectImap]
@@ -65,9 +64,8 @@ Future<SmtpClient> connectSmtp(
// clientDomain is the sending domain advertised in EHLO — use the host part // clientDomain is the sending domain advertised in EHLO — use the host part
// of the sender email, falling back to the SMTP host. // of the sender email, falling back to the SMTP host.
final atIndex = account.email.lastIndexOf('@'); final atIndex = account.email.lastIndexOf('@');
final clientDomain = atIndex != -1 final clientDomain =
? account.email.substring(atIndex + 1) atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
: account.smtpHost;
if (!account.smtpSsl && !isLocalhost(account.smtpHost)) { if (!account.smtpSsl && !isLocalhost(account.smtpHost)) {
throw Exception( throw Exception(
+21 -16
View File
@@ -26,14 +26,14 @@ class JmapClient {
String? uploadUrl, String? uploadUrl,
String? downloadUrl, String? downloadUrl,
String? eventSourceUrl, String? eventSourceUrl,
}) : _httpClient = httpClient, }) : _httpClient = httpClient,
_credentials = credentials, _credentials = credentials,
_apiUrl = apiUrl, _apiUrl = apiUrl,
_accountId = accountId, _accountId = accountId,
_capabilities = capabilities, _capabilities = capabilities,
_uploadUrl = uploadUrl, _uploadUrl = uploadUrl,
_downloadUrl = downloadUrl, _downloadUrl = downloadUrl,
_eventSourceUrl = eventSourceUrl; _eventSourceUrl = eventSourceUrl;
final http.Client _httpClient; final http.Client _httpClient;
final String _credentials; final String _credentials;
@@ -67,9 +67,12 @@ class JmapClient {
http.Response resp; http.Response resp;
var attempt = 0; var attempt = 0;
while (true) { while (true) {
resp = await httpClient resp = await httpClient.get(
.get(jmapUrl, headers: {'Authorization': 'Basic $credentials'}) jmapUrl,
.timeout(const Duration(seconds: 10)); headers: {
'Authorization': 'Basic $credentials',
},
).timeout(const Duration(seconds: 10));
if (resp.statusCode != 429 || attempt >= 4) { if (resp.statusCode != 429 || attempt >= 4) {
break; break;
} }
@@ -215,9 +218,12 @@ class JmapClient {
.replaceAll('{name}', Uri.encodeComponent(name)) .replaceAll('{name}', Uri.encodeComponent(name))
.replaceAll('{type}', Uri.encodeComponent(type)), .replaceAll('{type}', Uri.encodeComponent(type)),
); );
final resp = await _httpClient final resp = await _httpClient.get(
.get(url, headers: {'Authorization': 'Basic $_credentials'}) url,
.timeout(const Duration(seconds: 30)); headers: {
'Authorization': 'Basic $_credentials',
},
).timeout(const Duration(seconds: 30));
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw JmapException('Blob download failed (HTTP ${resp.statusCode})'); throw JmapException('Blob download failed (HTTP ${resp.statusCode})');
} }
@@ -240,8 +246,7 @@ class JmapClient {
static String _extractAccountId(Map<String, dynamic> session) { static String _extractAccountId(Map<String, dynamic> session) {
final primaryAccounts = session['primaryAccounts'] as Map<String, dynamic>?; final primaryAccounts = session['primaryAccounts'] as Map<String, dynamic>?;
final id = final id = primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ??
primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ??
primaryAccounts?['urn:ietf:params:jmap:core'] as String?; primaryAccounts?['urn:ietf:params:jmap:core'] as String?;
if (id != null) return id; if (id != null) return id;
+44 -34
View File
@@ -9,18 +9,18 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart'; import 'package:sharedinbox/data/imap/managesieve_client.dart';
import 'package:sharedinbox/data/jmap/jmap_client.dart'; import 'package:sharedinbox/data/jmap/jmap_client.dart';
typedef ManageSieveConnectFn = typedef ManageSieveConnectFn = Future<ManageSieveClient> Function({
Future<ManageSieveClient> Function({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, });
});
Future<ManageSieveClient> _defaultManageSieveConnect({ Future<ManageSieveClient> _defaultManageSieveConnect({
required String host, required String host,
required int port, required int port,
required bool useTls, required bool useTls,
}) => ManageSieveClient.connect(host: host, port: port, useTls: useTls); }) =>
ManageSieveClient.connect(host: host, port: port, useTls: useTls);
class SieveRepository { class SieveRepository {
SieveRepository( SieveRepository(
@@ -51,13 +51,16 @@ class SieveRepository {
}); });
} }
return _withJmap(account, (jmap) async { return _withJmap(account, (jmap) async {
final responses = await jmap.call([ final responses = await jmap.call(
[ [
'SieveScript/get', [
{'accountId': jmap.accountId, 'ids': null}, 'SieveScript/get',
'0', {'accountId': jmap.accountId, 'ids': null},
'0',
],
], ],
], withSieve: true); withSieve: true,
);
final result = _responseArgs(responses, 0, 'SieveScript/get'); final result = _responseArgs(responses, 0, 'SieveScript/get');
final list = result['list'] as List<dynamic>; final list = result['list'] as List<dynamic>;
return list.map((e) { return list.map((e) {
@@ -123,9 +126,12 @@ class SieveRepository {
id: {'name': name, 'blobId': blobId}, id: {'name': name, 'blobId': blobId},
}, },
}; };
final responses = await jmap.call([ final responses = await jmap.call(
['SieveScript/set', setArgs, '0'], [
], withSieve: true); ['SieveScript/set', setArgs, '0'],
],
withSieve: true,
);
final result = _responseArgs(responses, 0, 'SieveScript/set'); final result = _responseArgs(responses, 0, 'SieveScript/set');
if (id == null) { if (id == null) {
final created = result['created'] as Map<String, dynamic>?; final created = result['created'] as Map<String, dynamic>?;
@@ -164,16 +170,19 @@ class SieveRepository {
return; return;
} }
await _withJmap(account, (jmap) async { await _withJmap(account, (jmap) async {
final responses = await jmap.call([ final responses = await jmap.call(
[ [
'SieveScript/set', [
{ 'SieveScript/set',
'accountId': jmap.accountId, {
'destroy': [scriptId], 'accountId': jmap.accountId,
}, 'destroy': [scriptId],
'0', },
'0',
],
], ],
], withSieve: true); withSieve: true,
);
final result = _responseArgs(responses, 0, 'SieveScript/set'); final result = _responseArgs(responses, 0, 'SieveScript/set');
final notDestroyed = result['notDestroyed'] as Map<String, dynamic>?; final notDestroyed = result['notDestroyed'] as Map<String, dynamic>?;
if (notDestroyed != null && notDestroyed.containsKey(scriptId)) { if (notDestroyed != null && notDestroyed.containsKey(scriptId)) {
@@ -192,13 +201,16 @@ class SieveRepository {
return; return;
} }
await _withJmap(account, (jmap) async { await _withJmap(account, (jmap) async {
await jmap.call([ await jmap.call(
[ [
'SieveScript/activate', [
{'accountId': jmap.accountId, 'id': scriptId}, 'SieveScript/activate',
'0', {'accountId': jmap.accountId, 'id': scriptId},
'0',
],
], ],
], withSieve: true); withSieve: true,
);
}); });
} }
@@ -219,9 +231,8 @@ class SieveRepository {
throw Exception('Account has no JMAP URL'); throw Exception('Account has no JMAP URL');
} }
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
final username = account.username.isNotEmpty final username =
? account.username account.username.isNotEmpty ? account.username : account.email;
: account.email;
final jmap = await JmapClient.connect( final jmap = await JmapClient.connect(
httpClient: _httpClient, httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl), jmapUrl: Uri.parse(jmapUrl),
@@ -247,9 +258,8 @@ class SieveRepository {
throw Exception('Account has no ManageSieve host configured'); throw Exception('Account has no ManageSieve host configured');
} }
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
final username = account.username.isNotEmpty final username =
? account.username account.username.isNotEmpty ? account.username : account.email;
: account.email;
final client = await _manageSieveConnect( final client = await _manageSieveConnect(
host: host, host: host,
port: account.manageSievePort, port: account.manageSievePort,
@@ -23,15 +23,14 @@ class AccountRepositoryImpl implements AccountRepository {
Future<model.Account?> getAccount(String id) async { Future<model.Account?> getAccount(String id) async {
final row = await (_db.select( final row = await (_db.select(
_db.accounts, _db.accounts,
)..where((t) => t.id.equals(id))).getSingleOrNull(); )..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _toModel(row); return row == null ? null : _toModel(row);
} }
@override @override
Future<void> addAccount(model.Account account, String password) async { Future<void> addAccount(model.Account account, String password) async {
await _db await _db.into(_db.accounts).insertOnConflictUpdate(
.into(_db.accounts)
.insertOnConflictUpdate(
AccountsCompanion.insert( AccountsCompanion.insert(
id: account.id, id: account.id,
displayName: account.displayName, displayName: account.displayName,
@@ -59,7 +58,8 @@ class AccountRepositoryImpl implements AccountRepository {
Future<void> updateAccount(model.Account account, {String? password}) async { Future<void> updateAccount(model.Account account, {String? password}) async {
await (_db.update( await (_db.update(
_db.accounts, _db.accounts,
)..where((t) => t.id.equals(account.id))).write( )..where((t) => t.id.equals(account.id)))
.write(
AccountsCompanion( AccountsCompanion(
displayName: Value(account.displayName), displayName: Value(account.displayName),
email: Value(account.email), email: Value(account.email),
@@ -102,22 +102,22 @@ class AccountRepositoryImpl implements AccountRepository {
String _passwordKey(String accountId) => 'account_password_$accountId'; String _passwordKey(String accountId) => 'account_password_$accountId';
model.Account _toModel(Account row) => model.Account( model.Account _toModel(Account row) => model.Account(
id: row.id, id: row.id,
displayName: row.displayName, displayName: row.displayName,
email: row.email, email: row.email,
username: row.username, username: row.username,
type: model.AccountType.values.byName(row.accountType), type: model.AccountType.values.byName(row.accountType),
imapHost: row.imapHost, imapHost: row.imapHost,
imapPort: row.imapPort, imapPort: row.imapPort,
imapSsl: row.imapSsl, imapSsl: row.imapSsl,
smtpHost: row.smtpHost, smtpHost: row.smtpHost,
smtpPort: row.smtpPort, smtpPort: row.smtpPort,
smtpSsl: row.smtpSsl, smtpSsl: row.smtpSsl,
manageSieveHost: row.manageSieveHost, manageSieveHost: row.manageSieveHost,
manageSievePort: row.manageSievePort, manageSievePort: row.manageSievePort,
manageSieveSsl: row.manageSieveSsl, manageSieveSsl: row.manageSieveSsl,
manageSieveAvailable: row.manageSieveAvailable, manageSieveAvailable: row.manageSieveAvailable,
jmapUrl: row.jmapUrl, jmapUrl: row.jmapUrl,
verbose: row.verbose, verbose: row.verbose,
); );
} }
@@ -10,7 +10,7 @@ import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect}) DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
: _imapConnect = imapConnect; : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -51,9 +51,7 @@ class DraftRepositoryImpl implements DraftRepository {
); );
} }
final newId = await _db final newId = await _db.into(_db.drafts).insert(
.into(_db.drafts)
.insert(
DraftsCompanion.insert( DraftsCompanion.insert(
accountId: Value(accountId), accountId: Value(accountId),
replyToEmailId: Value(replyToEmailId), replyToEmailId: Value(replyToEmailId),
@@ -94,7 +92,8 @@ class DraftRepositoryImpl implements DraftRepository {
Future<SavedDraft?> getDraft(int id) async { Future<SavedDraft?> getDraft(int id) async {
final row = await (_db.select( final row = await (_db.select(
_db.drafts, _db.drafts,
)..where((t) => t.id.equals(id))).getSingleOrNull(); )..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _toModel(row); return row == null ? null : _toModel(row);
} }
@@ -111,9 +110,8 @@ class DraftRepositoryImpl implements DraftRepository {
final account = await _accounts.getAccount(accountId); final account = await _accounts.getAccount(accountId);
if (account == null || account.type != AccountType.imap) return; if (account == null || account.type != AccountType.imap) return;
final username = account.username.isNotEmpty final username =
? account.username account.username.isNotEmpty ? account.username : account.email;
: account.email;
imap.ImapClient? client; imap.ImapClient? client;
try { try {
client = await connect(account, username, password); client = await connect(account, username, password);
@@ -134,11 +132,11 @@ class DraftRepositoryImpl implements DraftRepository {
final messageCount = selectResult.messagesExists; final messageCount = selectResult.messagesExists;
// Upload local drafts that have no server counterpart. // Upload local drafts that have no server counterpart.
final localDrafts = final localDrafts = await (_db.select(_db.drafts)
await (_db.select(_db.drafts)..where( ..where(
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(), (t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
)) ))
.get(); .get();
for (final row in localDrafts) { for (final row in localDrafts) {
final builder = imap.MessageBuilder() final builder = imap.MessageBuilder()
@@ -152,8 +150,8 @@ class DraftRepositoryImpl implements DraftRepository {
targetMailboxPath: 'Drafts', targetMailboxPath: 'Drafts',
flags: [r'\Draft'], flags: [r'\Draft'],
); );
final uidList = appendResult.responseCodeAppendUid?.targetSequence final uidList =
.toList(); appendResult.responseCodeAppendUid?.targetSequence.toList();
final uid = (uidList != null && uidList.isNotEmpty) final uid = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString() ? uidList.first.toString()
: null; : null;
@@ -166,12 +164,11 @@ class DraftRepositoryImpl implements DraftRepository {
// Download server drafts not tracked locally. // Download server drafts not tracked locally.
if (messageCount > 0) { if (messageCount > 0) {
final knownServerIds = final knownServerIds = await (_db.select(_db.drafts)
await (_db.select(_db.drafts)..where( ..where(
(t) => (t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
t.accountId.equals(accountId) & t.imapServerId.isNotNull(), ))
)) .get();
.get();
final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet(); final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet();
final seq = imap.MessageSequence.fromAll(); final seq = imap.MessageSequence.fromAll();
@@ -182,9 +179,7 @@ class DraftRepositoryImpl implements DraftRepository {
if (msg.flags?.contains(r'\Deleted') ?? false) continue; if (msg.flags?.contains(r'\Deleted') ?? false) continue;
final env = msg.envelope; final env = msg.envelope;
final now = DateTime.now(); final now = DateTime.now();
await _db await _db.into(_db.drafts).insert(
.into(_db.drafts)
.insert(
DraftsCompanion.insert( DraftsCompanion.insert(
accountId: Value(accountId), accountId: Value(accountId),
toText: Value(_addressListToText(env?.to)), toText: Value(_addressListToText(env?.to)),
@@ -210,14 +205,14 @@ class DraftRepositoryImpl implements DraftRepository {
} }
SavedDraft _toModel(Draft row) => SavedDraft( SavedDraft _toModel(Draft row) => SavedDraft(
id: row.id, id: row.id,
accountId: row.accountId, accountId: row.accountId,
replyToEmailId: row.replyToEmailId, replyToEmailId: row.replyToEmailId,
toText: row.toText, toText: row.toText,
ccText: row.ccText, ccText: row.ccText,
subjectText: row.subjectText, subjectText: row.subjectText,
bodyText: row.bodyText, bodyText: row.bodyText,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
imapServerId: row.imapServerId, imapServerId: row.imapServerId,
); );
} }
File diff suppressed because it is too large Load Diff
@@ -17,8 +17,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
this._accounts, { this._accounts, {
ImapConnectFn imapConnect = connectImap, ImapConnectFn imapConnect = connectImap,
http.Client? httpClient, http.Client? httpClient,
}) : _imapConnect = imapConnect, }) : _imapConnect = imapConnect,
_httpClient = httpClient ?? http.Client(); _httpClient = httpClient ?? http.Client();
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -45,13 +45,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
String accountId, String accountId,
String role, String role,
) async { ) async {
final row = final row = await (_db.select(_db.mailboxes)
await (_db.select(_db.mailboxes) ..where(
..where( (t) => t.accountId.equals(accountId) & t.role.equals(role),
(t) => t.accountId.equals(accountId) & t.role.equals(role), )
) ..limit(1))
..limit(1)) .getSingleOrNull();
.getSingleOrNull();
return row == null ? null : _toModel(row); return row == null ? null : _toModel(row);
} }
@@ -85,7 +84,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
// folders the server doesn't tag with a special-use attribute. // folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select( final existingRows = await (_db.select(
_db.mailboxes, _db.mailboxes,
)..where((t) => t.accountId.equals(account.id))).get(); )..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role}; final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) { for (final mb in mailboxes) {
@@ -111,9 +111,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
// when the IMAP server does not expose a special-use attribute. // when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id]; final role = _imapRole(mb) ?? existingRoles[id];
await _db await _db.into(_db.mailboxes).insertOnConflictUpdate(
.into(_db.mailboxes)
.insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
accountId: account.id, accountId: account.id,
@@ -218,7 +216,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
for (final jmapId in destroyed) { for (final jmapId in destroyed) {
await (_db.delete( await (_db.delete(
_db.mailboxes, _db.mailboxes,
)..where((t) => t.id.equals('$accountId:$jmapId'))).go(); )..where((t) => t.id.equals('$accountId:$jmapId')))
.go();
} }
await _saveSyncState(accountId, 'Mailbox', newState); await _saveSyncState(accountId, 'Mailbox', newState);
@@ -239,9 +238,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
final dbId = '$accountId:$jmapId'; final dbId = '$accountId:$jmapId';
// For JMAP accounts, path stores the JMAP mailbox ID so that // For JMAP accounts, path stores the JMAP mailbox ID so that
// Email rows can reference it via mailboxPath. // Email rows can reference it via mailboxPath.
await _db await _db.into(_db.mailboxes).insertOnConflictUpdate(
.into(_db.mailboxes)
.insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: dbId, id: dbId,
accountId: accountId, accountId: accountId,
@@ -258,13 +255,13 @@ class MailboxRepositoryImpl implements MailboxRepository {
// ── sync_state helpers ──────────────────────────────────────────────────── // ── sync_state helpers ────────────────────────────────────────────────────
Future<String?> _loadSyncState(String accountId, String resourceType) async { Future<String?> _loadSyncState(String accountId, String resourceType) async {
final row = final row = await (_db.select(_db.syncStates)
await (_db.select(_db.syncStates)..where( ..where(
(t) => (t) =>
t.accountId.equals(accountId) & t.accountId.equals(accountId) &
t.resourceType.equals(resourceType), t.resourceType.equals(resourceType),
)) ))
.getSingleOrNull(); .getSingleOrNull();
return row?.state; return row?.state;
} }
@@ -273,9 +270,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
String resourceType, String resourceType,
String state, String state,
) async { ) async {
await _db await _db.into(_db.syncStates).insertOnConflictUpdate(
.into(_db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: accountId, accountId: accountId,
resourceType: resourceType, resourceType: resourceType,
@@ -304,14 +299,14 @@ class MailboxRepositoryImpl implements MailboxRepository {
} }
model.Mailbox _toModel(MailboxRow row) => model.Mailbox( model.Mailbox _toModel(MailboxRow row) => model.Mailbox(
id: row.id, id: row.id,
accountId: row.accountId, accountId: row.accountId,
path: row.path, path: row.path,
name: row.name, name: row.name,
unreadCount: row.unreadCount, unreadCount: row.unreadCount,
totalCount: row.totalCount, totalCount: row.totalCount,
role: row.role, role: row.role,
); );
/// Maps enough_mail special-use flags (RFC 6154) to JMAP role strings (RFC 8621). /// Maps enough_mail special-use flags (RFC 6154) to JMAP role strings (RFC 8621).
static String? _imapRole(imap.Mailbox mb) { static String? _imapRole(imap.Mailbox mb) {
@@ -328,7 +323,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete( await (_db.delete(
_db.mailboxes, _db.mailboxes,
)..where((t) => t.accountId.equals(accountId))).go(); )..where((t) => t.accountId.equals(accountId)))
.go();
} }
@override @override
@@ -364,9 +360,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
await client.logout(); await client.logout();
} }
final id = '${account.id}:$name'; final id = '${account.id}:$name';
await _db await _db.into(_db.mailboxes).insertOnConflictUpdate(
.into(_db.mailboxes)
.insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
accountId: account.id, accountId: account.id,
@@ -377,7 +371,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
final row = await (_db.select( final row = await (_db.select(
_db.mailboxes, _db.mailboxes,
)..where((t) => t.id.equals(id))).getSingle(); )..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -419,9 +414,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
} }
final dbId = '${account.id}:$newId'; final dbId = '${account.id}:$newId';
await _db await _db.into(_db.mailboxes).insertOnConflictUpdate(
.into(_db.mailboxes)
.insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: dbId, id: dbId,
accountId: account.id, accountId: account.id,
@@ -432,7 +425,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
final row = await (_db.select( final row = await (_db.select(
_db.mailboxes, _db.mailboxes,
)..where((t) => t.id.equals(dbId))).getSingle(); )..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row); return _toModel(row);
} }
} }
@@ -10,11 +10,10 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
@override @override
Future<List<String>> getRecentSearches() async { Future<List<String>> getRecentSearches() async {
final rows = final rows = await (_db.select(_db.searchHistoryEntries)
await (_db.select(_db.searchHistoryEntries) ..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)]) ..limit(_maxEntries))
..limit(_maxEntries)) .get();
.get();
return rows.map((r) => r.query).toList(); return rows.map((r) => r.query).toList();
} }
@@ -27,11 +26,10 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
// Remove existing entry for same query (deduplication). // Remove existing entry for same query (deduplication).
await (_db.delete( await (_db.delete(
_db.searchHistoryEntries, _db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed))).go(); )..where((t) => t.query.equals(trimmed)))
.go();
await _db await _db.into(_db.searchHistoryEntries).insert(
.into(_db.searchHistoryEntries)
.insert(
SearchHistoryEntriesCompanion.insert( SearchHistoryEntriesCompanion.insert(
query: trimmed, query: trimmed,
searchedAt: DateTime.now(), searchedAt: DateTime.now(),
@@ -39,17 +37,17 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
); );
// Prune to the most recent _maxEntries. // Prune to the most recent _maxEntries.
final keepIds = final keepIds = await (_db.select(_db.searchHistoryEntries)
await (_db.select(_db.searchHistoryEntries) ..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)]) ..limit(_maxEntries))
..limit(_maxEntries)) .map((r) => r.id)
.map((r) => r.id) .get();
.get();
if (keepIds.isNotEmpty) { if (keepIds.isNotEmpty) {
await (_db.delete( await (_db.delete(
_db.searchHistoryEntries, _db.searchHistoryEntries,
)..where((t) => t.id.isNotIn(keepIds))).go(); )..where((t) => t.id.isNotIn(keepIds)))
.go();
} }
}); });
} }
@@ -23,9 +23,7 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
final keyIdHex = _hex(material.keyId); final keyIdHex = _hex(material.keyId);
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20)); final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
await _db await _db.into(_db.shareKeys).insert(
.into(_db.shareKeys)
.insert(
ShareKeysCompanion.insert( ShareKeysCompanion.insert(
id: keyIdHex, id: keyIdHex,
publicKey: base64.encode(material.publicKeyBytes), publicKey: base64.encode(material.publicKeyBytes),
@@ -44,7 +42,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
final keyIdHex = _hex(keyId); final keyIdHex = _hex(keyId);
final row = await (_db.select( final row = await (_db.select(
_db.shareKeys, _db.shareKeys,
)..where((t) => t.id.equals(keyIdHex))).getSingleOrNull(); )..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull();
if (row == null) return null; if (row == null) return null;
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null; if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
@@ -58,8 +57,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
Future<void> _pruneExpired() async { Future<void> _pruneExpired() async {
await (_db.delete( await (_db.delete(
_db.shareKeys, _db.shareKeys,
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()))) )..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
.go(); .go();
} }
@@ -27,9 +27,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
String? protocolLog, String? protocolLog,
}) async { }) async {
await _db.transaction(() async { await _db.transaction(() async {
final logId = await _db final logId = await _db.into(_db.syncLogs).insert(
.into(_db.syncLogs)
.insert(
SyncLogsCompanion.insert( SyncLogsCompanion.insert(
accountId: accountId, accountId: accountId,
result: success ? 'ok' : 'error', result: success ? 'ok' : 'error',
@@ -48,9 +46,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
), ),
); );
for (final s in mailboxStats) { for (final s in mailboxStats) {
await _db await _db.into(_db.syncLogMailboxes).insert(
.into(_db.syncLogMailboxes)
.insert(
SyncLogMailboxesCompanion.insert( SyncLogMailboxesCompanion.insert(
syncLogId: logId, syncLogId: logId,
mailboxPath: s.mailboxPath, mailboxPath: s.mailboxPath,
@@ -74,11 +70,10 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
return logsQuery.watch().asyncMap((rows) async { return logsQuery.watch().asyncMap((rows) async {
final entries = <SyncLogEntry>[]; final entries = <SyncLogEntry>[];
for (final r in rows) { for (final r in rows) {
final mailboxRows = final mailboxRows = await (_db.select(_db.syncLogMailboxes)
await (_db.select(_db.syncLogMailboxes) ..where((t) => t.syncLogId.equals(r.id))
..where((t) => t.syncLogId.equals(r.id)) ..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)]))
..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)])) .get();
.get();
entries.add( entries.add(
SyncLogEntry( SyncLogEntry(
id: r.id, id: r.id,
@@ -11,9 +11,7 @@ class UndoRepositoryImpl implements UndoRepository {
@override @override
Future<void> saveAction(UndoAction action) async { Future<void> saveAction(UndoAction action) async {
await _db await _db.into(_db.undoActions).insert(
.into(_db.undoActions)
.insert(
UndoActionsCompanion.insert( UndoActionsCompanion.insert(
id: action.id, id: action.id,
accountId: action.accountId, accountId: action.accountId,
@@ -31,11 +29,10 @@ class UndoRepositoryImpl implements UndoRepository {
@override @override
Future<List<UndoAction>> getHistory({int limit = 10}) async { Future<List<UndoAction>> getHistory({int limit = 10}) async {
final rows = final rows = await (_db.select(_db.undoActions)
await (_db.select(_db.undoActions) ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) ..limit(limit))
..limit(limit)) .get();
.get();
return rows.map((row) { return rows.map((row) {
return UndoAction.fromJson( return UndoAction.fromJson(
jsonDecode(row.dataJson) as Map<String, dynamic>, jsonDecode(row.dataJson) as Map<String, dynamic>,
@@ -13,14 +13,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
Stream<pref.UserPreferences> observePreferences() { Stream<pref.UserPreferences> observePreferences() {
return (_db.select( return (_db.select(
_db.userPreferences, _db.userPreferences,
)..where((t) => t.id.equals(_rowId))).watchSingleOrNull().map(_rowToModel); )..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
} }
@override @override
Future<void> updateMenuPosition(pref.MenuPosition position) async { Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db await _db.into(_db.userPreferences).insertOnConflictUpdate(
.into(_db.userPreferences)
.insertOnConflictUpdate(
UserPreferencesCompanion( UserPreferencesCompanion(
id: const Value(_rowId), id: const Value(_rowId),
menuPosition: Value(position.name), menuPosition: Value(position.name),
@@ -30,9 +30,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
@override @override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async { Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db await _db.into(_db.userPreferences).insertOnConflictUpdate(
.into(_db.userPreferences)
.insertOnConflictUpdate(
UserPreferencesCompanion( UserPreferencesCompanion(
id: const Value(_rowId), id: const Value(_rowId),
mailViewButtonPosition: Value(position.name), mailViewButtonPosition: Value(position.name),
@@ -44,9 +42,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
Future<void> updateAfterMailViewAction( Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action, pref.AfterMailViewAction action,
) async { ) async {
await _db await _db.into(_db.userPreferences).insertOnConflictUpdate(
.into(_db.userPreferences)
.insertOnConflictUpdate(
UserPreferencesCompanion( UserPreferencesCompanion(
id: const Value(_rowId), id: const Value(_rowId),
afterMailViewAction: Value(action.name), afterMailViewAction: Value(action.name),
+30 -32
View File
@@ -111,10 +111,10 @@ final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider)); return SyncLogRepositoryImpl(ref.watch(dbProvider));
}); });
final syncLastErrorProvider = StreamProvider.autoDispose final syncLastErrorProvider =
.family<String?, String>((ref, accountId) { StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId); return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
}); });
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) { final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
final runner = ReliabilityRunner( final runner = ReliabilityRunner(
@@ -127,13 +127,14 @@ final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
return runner; return runner;
}); });
final syncHealthProvider = StreamProvider.autoDispose final syncHealthProvider =
.family<SyncHealthRow?, String>((ref, accountId) { StreamProvider.autoDispose.family<SyncHealthRow?, String>((ref, accountId) {
final db = ref.watch(dbProvider); final db = ref.watch(dbProvider);
return (db.select( return (db.select(
db.syncHealth, db.syncHealth,
)..where((t) => t.accountId.equals(accountId))).watchSingleOrNull(); )..where((t) => t.accountId.equals(accountId)))
}); .watchSingleOrNull();
});
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>(( final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
ref, ref,
@@ -195,8 +196,8 @@ final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
final emailDetailProvider = AsyncNotifierProvider.autoDispose final emailDetailProvider = AsyncNotifierProvider.autoDispose
.family<EmailDetailNotifier, (Email?, EmailBody), String>( .family<EmailDetailNotifier, (Email?, EmailBody), String>(
EmailDetailNotifier.new, EmailDetailNotifier.new,
); );
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> { class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId); EmailDetailNotifier(this._emailId);
@@ -214,29 +215,26 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
} }
} }
final accountByIdProvider = StreamProvider.autoDispose final accountByIdProvider =
.family<model.Account?, String>((ref, accountId) { StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref return ref.watch(accountRepositoryProvider).observeAccounts().map(
.watch(accountRepositoryProvider) (accounts) => accounts.cast<model.Account?>().firstWhere(
.observeAccounts()
.map(
(accounts) => accounts.cast<model.Account?>().firstWhere(
(a) => a?.id == accountId, (a) => a?.id == accountId,
orElse: () => null, orElse: () => null,
), ),
); );
}); });
final accountConnectionStatusProvider = FutureProvider.autoDispose final accountConnectionStatusProvider =
.family<void, String>((ref, accountId) async { FutureProvider.autoDispose.family<void, String>((ref, accountId) async {
final repo = ref.read(accountRepositoryProvider); final repo = ref.read(accountRepositoryProvider);
final account = await repo.getAccount(accountId); final account = await repo.getAccount(accountId);
if (account == null) throw Exception('Account not found'); if (account == null) throw Exception('Account not found');
final password = await repo.getPassword(accountId); final password = await repo.getPassword(accountId);
await ref await ref
.read(connectionTestServiceProvider) .read(connectionTestServiceProvider)
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>(( final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
ref, ref,
+3 -3
View File
@@ -20,9 +20,9 @@ void main({List<Override> overrides = const []}) async {
// Catch errors during build (e.g. layout exceptions) and show CrashScreen. // Catch errors during build (e.g. layout exceptions) and show CrashScreen.
ErrorWidget.builder = (details) => CrashScreen( ErrorWidget.builder = (details) => CrashScreen(
exception: details.exception, exception: details.exception,
stackTrace: details.stack, stackTrace: details.stack,
); );
// Catch framework-level errors (e.g. from gestures, timers). // Catch framework-level errors (e.g. from gestures, timers).
FlutterError.onError = (details) { FlutterError.onError = (details) {
+4 -6
View File
@@ -153,12 +153,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
stream: _accountsStream, stream: _accountsStream,
builder: (context, accountSnapshot) { builder: (context, accountSnapshot) {
final accounts = accountSnapshot.data ?? []; final accounts = accountSnapshot.data ?? [];
final imapCount = accounts final imapCount =
.where((a) => a.type == AccountType.imap) accounts.where((a) => a.type == AccountType.imap).length;
.length; final jmapCount =
final jmapCount = accounts accounts.where((a) => a.type == AccountType.jmap).length;
.where((a) => a.type == AccountType.jmap)
.length;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('About')), appBar: AppBar(title: const Text('About')),
+15 -15
View File
@@ -209,24 +209,24 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step.showingPubKey => _buildPubKeyView(context), _Step.showingPubKey => _buildPubKeyView(context),
_Step.scanning => _buildScannerView(context), _Step.scanning => _buildScannerView(context),
_Step.importing => const Center( _Step.importing => const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
CircularProgressIndicator(), CircularProgressIndicator(),
SizedBox(height: 16), SizedBox(height: 16),
Text('Importing accounts…'), Text('Importing accounts…'),
], ],
),
), ),
),
_Step.done => const Center( _Step.done => const Center(
child: Icon(Icons.check_circle, size: 64, color: Colors.green), child: Icon(Icons.check_circle, size: 64, color: Colors.green),
), ),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text('Error: $_errorMessage'), child: Text('Error: $_errorMessage'),
),
), ),
),
}, },
); );
} }
+6 -8
View File
@@ -117,10 +117,8 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
} }
// Load all available accounts. // Load all available accounts.
final accounts = await ref final accounts =
.read(accountRepositoryProvider) await ref.read(accountRepositoryProvider).observeAccounts().first;
.observeAccounts()
.first;
if (!mounted) return; if (!mounted) return;
@@ -197,11 +195,11 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
_Step.selectAccounts => _buildSelectStep(context), _Step.selectAccounts => _buildSelectStep(context),
_Step.showEncrypted => _buildEncryptedQrStep(context), _Step.showEncrypted => _buildEncryptedQrStep(context),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text('Error: $_errorMessage'), child: Text('Error: $_errorMessage'),
),
), ),
),
}, },
); );
} }
+14 -15
View File
@@ -94,12 +94,12 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
_jmapApiUrlCtrl.text = sessionUrl; _jmapApiUrlCtrl.text = sessionUrl;
setState(() => _step = _Step.jmapForm); setState(() => _step = _Step.jmapForm);
case ImapSmtpDiscovery( case ImapSmtpDiscovery(
:final imapHost, :final imapHost,
:final imapPort, :final imapPort,
:final smtpHost, :final smtpHost,
:final smtpPort, :final smtpPort,
:final smtpSsl, :final smtpSsl,
): ):
_imapHostCtrl.text = imapHost; _imapHostCtrl.text = imapHost;
_imapPortCtrl.text = imapPort.toString(); _imapPortCtrl.text = imapPort.toString();
_smtpHostCtrl.text = smtpHost; _smtpHostCtrl.text = smtpHost;
@@ -116,13 +116,13 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
} }
Account _buildJmapAccount() => Account( Account _buildJmapAccount() => Account(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: _displayNameCtrl.text.trim(), displayName: _displayNameCtrl.text.trim(),
email: _emailCtrl.text.trim(), email: _emailCtrl.text.trim(),
username: _usernameCtrl.text.trim(), username: _usernameCtrl.text.trim(),
type: AccountType.jmap, type: AccountType.jmap,
jmapUrl: _jmapApiUrlCtrl.text.trim(), jmapUrl: _jmapApiUrlCtrl.text.trim(),
); );
Account _buildImapAccount() { Account _buildImapAccount() {
final imapHost = _imapHostCtrl.text.trim(); final imapHost = _imapHostCtrl.text.trim();
@@ -494,8 +494,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
validator: validator: validator ??
validator ??
(required (required
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null), : null),
+32 -31
View File
@@ -51,37 +51,38 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
body: _loading body: _loading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _emails!.isEmpty : _emails!.isEmpty
? const Center(child: Text('No emails')) ? const Center(child: Text('No emails'))
: ListView.builder( : ListView.builder(
itemCount: _emails!.length, itemCount: _emails!.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final e = _emails![i]; final e = _emails![i];
final sender = e.from.isNotEmpty final sender = e.from.isNotEmpty
? (e.from.first.name ?? e.from.first.email) ? (e.from.first.name ?? e.from.first.email)
: '(unknown)'; : '(unknown)';
return ListTile( return ListTile(
leading: Icon( leading: Icon(
e.isSeen ? Icons.mail_outline : Icons.mail, e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary, color:
), e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
title: Text(sender), ),
subtitle: Text( title: Text(sender),
e.subject ?? '(no subject)', subtitle: Text(
maxLines: 1, e.subject ?? '(no subject)',
overflow: TextOverflow.ellipsis, maxLines: 1,
), overflow: TextOverflow.ellipsis,
trailing: Text( ),
e.mailboxPath, trailing: Text(
style: Theme.of(ctx).textTheme.bodySmall, e.mailboxPath,
), style: Theme.of(ctx).textTheme.bodySmall,
onTap: () => context.push( ),
'/accounts/${widget.accountId}/mailboxes' onTap: () => context.push(
'/${Uri.encodeComponent(e.mailboxPath)}' '/accounts/${widget.accountId}/mailboxes'
'/emails/${Uri.encodeComponent(e.id)}', '/${Uri.encodeComponent(e.mailboxPath)}'
), '/emails/${Uri.encodeComponent(e.id)}',
); ),
}, );
), },
),
); );
} }
} }
+7 -12
View File
@@ -70,8 +70,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
unawaited(_loadAccounts()); unawaited(_loadAccounts());
// Only restore if no prefill fields were provided (avoids overwriting a // Only restore if no prefill fields were provided (avoids overwriting a
// fresh reply with an old draft from a previous reply to the same email). // fresh reply with an old draft from a previous reply to the same email).
final hasPrefill = final hasPrefill = widget.prefillTo != null ||
widget.prefillTo != null ||
widget.prefillSubject != null || widget.prefillSubject != null ||
widget.prefillBody != null; widget.prefillBody != null;
if (!hasPrefill) unawaited(_restoreDraft()); if (!hasPrefill) unawaited(_restoreDraft());
@@ -82,10 +81,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
} }
Future<void> _loadAccounts() async { Future<void> _loadAccounts() async {
final accounts = await ref final accounts =
.read(accountRepositoryProvider) await ref.read(accountRepositoryProvider).observeAccounts().first;
.observeAccounts()
.first;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_accounts = accounts; _accounts = accounts;
@@ -224,9 +221,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
} }
setState(() => _sending = true); setState(() => _sending = true);
try { try {
final account = (await ref final account =
.read(accountRepositoryProvider) (await ref.read(accountRepositoryProvider).getAccount(_accountId!))!;
.getAccount(_accountId!))!;
final draft = EmailDraft( final draft = EmailDraft(
from: EmailAddress(name: account.displayName, email: account.email), from: EmailAddress(name: account.displayName, email: account.email),
to: _to.text to: _to.text
@@ -399,9 +395,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
displayStringForOption: (option) { displayStringForOption: (option) {
final text = ctrl.text; final text = ctrl.text;
final lastComma = text.lastIndexOf(','); final lastComma = text.lastIndexOf(',');
final prefix = lastComma >= 0 final prefix =
? '${text.substring(0, lastComma + 1)} ' lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : '';
: '';
return '$prefix${option.email}, '; return '$prefix${option.email}, ';
}, },
optionsBuilder: (value) async { optionsBuilder: (value) async {
+8 -12
View File
@@ -117,8 +117,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
int.tryParse(_sievePortCtrl.text) ?? account.manageSievePort; int.tryParse(_sievePortCtrl.text) ?? account.manageSievePort;
// Reset the cached probe result when any field that affects the probe // Reset the cached probe result when any field that affects the probe
// changed; the post-save probe will refill it. // changed; the post-save probe will refill it.
final sieveSettingsChanged = final sieveSettingsChanged = imapHost != account.imapHost ||
imapHost != account.imapHost ||
sieveHost != account.manageSieveHost || sieveHost != account.manageSieveHost ||
sievePort != account.manageSievePort || sievePort != account.manageSievePort ||
_sieveSsl != account.manageSieveSsl; _sieveSsl != account.manageSieveSsl;
@@ -139,12 +138,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
manageSieveHost: sieveHost, manageSieveHost: sieveHost,
manageSievePort: sievePort, manageSievePort: sievePort,
manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true, manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true,
manageSieveAvailable: sieveSettingsChanged manageSieveAvailable:
? null sieveSettingsChanged ? null : account.manageSieveAvailable,
: account.manageSieveAvailable, jmapUrl:
jmapUrl: _jmapUrlCtrl.text.trim().isEmpty _jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
? null
: _jmapUrlCtrl.text.trim(),
verbose: _verbose, verbose: _verbose,
); );
} }
@@ -154,8 +151,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
final password = _passwordCtrl.text.isNotEmpty final password = _passwordCtrl.text.isNotEmpty
? _passwordCtrl.text ? _passwordCtrl.text
: await ref : await ref
.read(accountRepositoryProvider) .read(accountRepositoryProvider)
.getPassword(widget.accountId); .getPassword(widget.accountId);
setState(() { setState(() {
_tryTesting = true; _tryTesting = true;
_tryOk = null; _tryOk = null;
@@ -395,8 +392,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
validator: validator: validator ??
validator ??
(required (required
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null), : null),
+17 -30
View File
@@ -55,8 +55,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final header = detail.value?.$1; final header = detail.value?.$1;
final body = detail.value?.$2; final body = detail.value?.$2;
final isMobile = final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS; defaultTargetPlatform == TargetPlatform.iOS;
return Scaffold( return Scaffold(
@@ -94,9 +93,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (header != null) { if (header != null) {
unawaited( unawaited(
ref ref.read(undoServiceProvider.notifier).pushAction(
.read(undoServiceProvider.notifier)
.pushAction(
UndoAction( UndoAction(
id: DateTime.now().toIso8601String(), id: DateTime.now().toIso8601String(),
accountId: header.accountId, accountId: header.accountId,
@@ -324,9 +321,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
Future<String> _quotedBody(Email header, EmailBody? body) async { Future<String> _quotedBody(Email header, EmailBody? body) async {
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : ''; final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
final from = header.from.isNotEmpty final from =
? header.from.first.toString() header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
: '(unknown)';
final rawText = body?.textBody; final rawText = body?.textBody;
final text = (rawText != null && rawText.isNotEmpty) final text = (rawText != null && rawText.isNotEmpty)
? rawText ? rawText
@@ -340,9 +336,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
Email header, Email header,
EmailBody? body, EmailBody? body,
) async { ) async {
final account = await ref final account =
.read(accountRepositoryProvider) await ref.read(accountRepositoryProvider).getAccount(header.accountId);
.getAccount(header.accountId);
final ownEmail = account?.email.toLowerCase() ?? ''; final ownEmail = account?.email.toLowerCase() ?? '';
final seen = <String>{}; final seen = <String>{};
@@ -445,9 +440,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.moveEmail(widget.emailId, mailbox.path); .moveEmail(widget.emailId, mailbox.path);
unawaited( unawaited(
ref ref.read(undoServiceProvider.notifier).pushAction(
.read(undoServiceProvider.notifier)
.pushAction(
UndoAction( UndoAction(
id: DateTime.now().toIso8601String(), id: DateTime.now().toIso8601String(),
accountId: header.accountId, accountId: header.accountId,
@@ -483,9 +476,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.moveEmail(widget.emailId, mailbox.path); .moveEmail(widget.emailId, mailbox.path);
unawaited( unawaited(
ref ref.read(undoServiceProvider.notifier).pushAction(
.read(undoServiceProvider.notifier)
.pushAction(
UndoAction( UndoAction(
id: DateTime.now().toIso8601String(), id: DateTime.now().toIso8601String(),
accountId: header.accountId, accountId: header.accountId,
@@ -522,14 +513,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final nextEmailId = await _getNextEmailIdIfNeeded(header); final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = await mailboxRepo final mailboxes =
.observeMailboxes(header.accountId) await mailboxRepo.observeMailboxes(header.accountId).first;
.first;
// Remove the current mailbox from the list. // Remove the current mailbox from the list.
final destinations = mailboxes final destinations =
.where((m) => m.path != header.mailboxPath) mailboxes.where((m) => m.path != header.mailboxPath).toList();
.toList();
if (!context.mounted) return; if (!context.mounted) return;
@@ -559,9 +548,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen); await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
unawaited( unawaited(
ref ref.read(undoServiceProvider.notifier).pushAction(
.read(undoServiceProvider.notifier)
.pushAction(
UndoAction( UndoAction(
id: DateTime.now().toIso8601String(), id: DateTime.now().toIso8601String(),
accountId: header.accountId, accountId: header.accountId,
@@ -641,8 +628,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
Text( Text(
fmtSize(raw.length), fmtSize(raw.length),
style: Theme.of(ctx).textTheme.bodySmall?.copyWith( style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.outline, color: Theme.of(ctx).colorScheme.outline,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Flexible( Flexible(
@@ -822,8 +809,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text( child: Text(
row.label, row.label,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith( style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
), ),
), ),
], ],
+46 -42
View File
@@ -92,9 +92,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
void _clearSelection() => setState(() { void _clearSelection() => setState(() {
_selectedThreadIds.clear(); _selectedThreadIds.clear();
_selectedSearchIds.clear(); _selectedSearchIds.clear();
}); });
void _selectAll() { void _selectAll() {
setState(() { setState(() {
@@ -182,9 +182,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
AsyncValue<Account?> accountAsync, { AsyncValue<Account?> accountAsync, {
required bool menuAtBottom, required bool menuAtBottom,
}) { }) {
final selectionCount = _searching final selectionCount =
? _selectedSearchIds.length _searching ? _selectedSearchIds.length : _selectedThreadIds.length;
: _selectedThreadIds.length;
return AppBar( return AppBar(
automaticallyImplyLeading: !menuAtBottom, automaticallyImplyLeading: !menuAtBottom,
@@ -278,8 +277,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
tooltip: isSyncing tooltip: isSyncing
? 'Syncing…' ? 'Syncing…'
: hasError : hasError
? 'Sync error' ? 'Sync error'
: 'Sync', : 'Sync',
icon: isSyncing icon: isSyncing
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
@@ -287,8 +286,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: hasError : hasError
? const Icon(Icons.sync_problem, color: Colors.red) ? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync), : const Icon(Icons.sync),
onPressed: isSyncing onPressed: isSyncing
? null ? null
: () async { : () async {
@@ -466,7 +465,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
final originalEmails = (await Future.wait( final originalEmails = (await Future.wait(
ids.map((id) => repo.getEmail(id)), ids.map((id) => repo.getEmail(id)),
)).whereType<Email>().toList(); ))
.whereType<Email>()
.toList();
for (final id in ids) { for (final id in ids) {
await repo.moveEmail(id, mailbox.path); await repo.moveEmail(id, mailbox.path);
@@ -485,10 +486,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
Future<void> _batchArchive() => _batchMoveToRole( Future<void> _batchArchive() => _batchMoveToRole(
'archive', 'archive',
dialogTitle: 'No archive folder found', dialogTitle: 'No archive folder found',
createFolderName: 'Archive', createFolderName: 'Archive',
); );
Future<void> _refreshSearchAndPopIfEmpty() async { Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return; if (!mounted || !_searching) return;
@@ -527,7 +528,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// This is especially important for IMAP where we hard-delete the row locally. // This is especially important for IMAP where we hard-delete the row locally.
final originalEmails = (await Future.wait( final originalEmails = (await Future.wait(
ids.map((id) => repo.getEmail(id)), ids.map((id) => repo.getEmail(id)),
)).whereType<Email>().toList(); ))
.whereType<Email>()
.toList();
String? lastDestPath; String? lastDestPath;
for (final id in ids) { for (final id in ids) {
@@ -566,10 +569,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
Future<void> _batchMarkSpam() => _batchMoveToRole( Future<void> _batchMarkSpam() => _batchMoveToRole(
'junk', 'junk',
dialogTitle: 'No spam folder found', dialogTitle: 'No spam folder found',
createFolderName: 'Junk', createFolderName: 'Junk',
); );
Future<void> _batchMove() async { Future<void> _batchMove() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
@@ -577,9 +580,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
.read(mailboxRepositoryProvider) .read(mailboxRepositoryProvider)
.observeMailboxes(widget.accountId) .observeMailboxes(widget.accountId)
.first; .first;
final destinations = mailboxes final destinations =
.where((m) => m.path != widget.mailboxPath) mailboxes.where((m) => m.path != widget.mailboxPath).toList();
.toList();
if (!mounted) return; if (!mounted) return;
@@ -611,7 +613,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
final originalEmails = (await Future.wait( final originalEmails = (await Future.wait(
ids.map((id) => repo.getEmail(id)), ids.map((id) => repo.getEmail(id)),
)).whereType<Email>().toList(); ))
.whereType<Email>()
.toList();
for (final id in ids) { for (final id in ids) {
await repo.moveEmail(id, chosen); await repo.moveEmail(id, chosen);
@@ -642,7 +646,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Fetch full email data before snoozing so we can restore them if user clicks Undo. // Fetch full email data before snoozing so we can restore them if user clicks Undo.
final originalEmails = (await Future.wait( final originalEmails = (await Future.wait(
ids.map((id) => repo.getEmail(id)), ids.map((id) => repo.getEmail(id)),
)).whereType<Email>().toList(); ))
.whereType<Email>()
.toList();
for (final id in ids) { for (final id in ids) {
await repo.snoozeEmail(id, until); await repo.snoozeEmail(id, until);
@@ -683,10 +689,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
final t = threads[i]; final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId); final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames = t.participants final senderNames =
.map((a) => a.name ?? a.email) t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
.take(3)
.join(', ');
final tile = ListTile( final tile = ListTile(
leading: SizedBox( leading: SizedBox(
@@ -698,9 +702,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
) )
: Icon( : Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline, t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread color:
? Theme.of(ctx).colorScheme.primary t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
: null,
), ),
), ),
title: Row( title: Row(
@@ -760,12 +763,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
onTap: _selecting onTap: _selecting
? () => _toggleThreadSelection(t) ? () => _toggleThreadSelection(t)
: t.messageCount > 1 : t.messageCount > 1
? () => context.push( ? () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}', '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
) )
: () => context.push( : () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}', '/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
), ),
onLongPress: () => _toggleThreadSelection(t), onLongPress: () => _toggleThreadSelection(t),
); );
@@ -773,9 +776,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// (single-email threads) or the whole thread. // (single-email threads) or the whole thread.
return Dismissible( return Dismissible(
key: ValueKey(t.threadId), key: ValueKey(t.threadId),
direction: _selecting direction:
? DismissDirection.none _selecting ? DismissDirection.none : DismissDirection.horizontal,
: DismissDirection.horizontal,
background: _swipeBackground( background: _swipeBackground(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
color: Colors.green, color: Colors.green,
@@ -797,7 +799,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Fetch full email data before moving/deleting. // Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait( final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)), t.emailIds.map((id) => repo.getEmail(id)),
)).whereType<Email>().toList(); ))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) { if (direction == DismissDirection.startToEnd) {
final archive = await ref final archive = await ref
+6 -6
View File
@@ -84,9 +84,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
emailRepo.getEmailsByAddress(widget.accountId, query), emailRepo.getEmailsByAddress(widget.accountId, query),
).wait; ).wait;
final matchedMailboxes = final matchedMailboxes = allMailboxes
allMailboxes.where((m) => _hasWordPrefix(m.name, ql)).toList() .where((m) => _hasWordPrefix(m.name, ql))
..sort(compareMailboxes); .toList()
..sort(compareMailboxes);
// Collect unique addresses from address-search results where the // Collect unique addresses from address-search results where the
// email or display name contains the query. // email or display name contains the query.
@@ -306,9 +307,8 @@ class _FolderTile extends StatelessWidget {
: null, : null,
), ),
subtitle: Text(accountId, style: Theme.of(context).textTheme.bodySmall), subtitle: Text(accountId, style: Theme.of(context).textTheme.bodySmall),
trailing: mb.unreadCount > 0 trailing:
? Badge(label: Text('${mb.unreadCount}')) mb.unreadCount > 0 ? Badge(label: Text('${mb.unreadCount}')) : null,
: null,
onTap: () => context.go( onTap: () => context.go(
'/accounts/$accountId/mailboxes' '/accounts/$accountId/mailboxes'
'/${Uri.encodeComponent(mb.path)}/emails', '/${Uri.encodeComponent(mb.path)}/emails',
+6 -10
View File
@@ -56,11 +56,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
try { try {
final content = widget.isLocal final content = widget.isLocal
? await ref ? await ref
.read(localSieveRepositoryProvider) .read(localSieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId) .getScriptContent(widget.accountId, widget.script!.blobId)
: await ref : await ref
.read(sieveRepositoryProvider) .read(sieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId); .getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) { if (mounted) {
_contentController.text = content; _contentController.text = content;
setState(() => _loadingContent = false); setState(() => _loadingContent = false);
@@ -87,18 +87,14 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
}); });
try { try {
if (widget.isLocal) { if (widget.isLocal) {
await ref await ref.read(localSieveRepositoryProvider).saveScript(
.read(localSieveRepositoryProvider)
.saveScript(
widget.accountId, widget.accountId,
id: widget.script?.id, id: widget.script?.id,
name: name, name: name,
content: _contentController.text, content: _contentController.text,
); );
} else { } else {
await ref await ref.read(sieveRepositoryProvider).saveScript(
.read(sieveRepositoryProvider)
.saveScript(
widget.accountId, widget.accountId,
id: widget.script?.id, id: widget.script?.id,
name: name, name: name,
+9 -9
View File
@@ -46,11 +46,11 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
try { try {
final scripts = widget.isLocal final scripts = widget.isLocal
? await ref ? await ref
.read(localSieveRepositoryProvider) .read(localSieveRepositoryProvider)
.listScripts(widget.accountId) .listScripts(widget.accountId)
: await ref : await ref
.read(sieveRepositoryProvider) .read(sieveRepositoryProvider)
.listScripts(widget.accountId); .listScripts(widget.accountId);
if (mounted) { if (mounted) {
setState(() { setState(() {
_scripts = scripts; _scripts = scripts;
@@ -207,10 +207,10 @@ class _SieveSourceBanner extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final text = isLocal final text = isLocal
? 'Local Filters run Sieve scripts directly on this device. ' ? 'Local Filters run Sieve scripts directly on this device. '
'Remote Filters, which run on the mail server, are configured separately.' 'Remote Filters, which run on the mail server, are configured separately.'
: 'Remote Filters run Sieve scripts on the mail server ' : 'Remote Filters run Sieve scripts on the mail server '
'(ManageSieve or JMAP). ' '(ManageSieve or JMAP). '
'Local Filters, which run on this device, are configured separately.'; 'Local Filters, which run on this device, are configured separately.';
return Container( return Container(
width: double.infinity, width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
@@ -228,8 +228,8 @@ class _SieveSourceBanner extends StatelessWidget {
child: Text( child: Text(
text, text,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
), ),
], ],
+31 -34
View File
@@ -40,8 +40,8 @@ String _buildSyncEntryMarkdown(SyncLogEntry entry) {
final statusLabel = entry.isOk final statusLabel = entry.isOk
? 'OK' ? 'OK'
: entry.isPermanent : entry.isPermanent
? 'Error (permanent)' ? 'Error (permanent)'
: 'Error'; : 'Error';
buf.writeln('| Status | $statusLabel |'); buf.writeln('| Status | $statusLabel |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |'); buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |'); buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
@@ -98,16 +98,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
.read(syncLogRepositoryProvider) .read(syncLogRepositoryProvider)
.observeSyncLogs(widget.accountId) .observeSyncLogs(widget.accountId)
.listen((entries) { .listen((entries) {
setState(() { setState(() {
if (_syncing && if (_syncing &&
_presynCount != null && _presynCount != null &&
entries.length > _presynCount!) { entries.length > _presynCount!) {
_syncing = false; _syncing = false;
_presynCount = null; _presynCount = null;
} }
_entries = entries; _entries = entries;
}); });
}); });
} }
@override @override
@@ -125,10 +125,8 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
} }
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async { Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
final accounts = await ref final accounts =
.read(accountRepositoryProvider) await ref.read(accountRepositoryProvider).observeAccounts().first;
.observeAccounts()
.first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length; final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length; final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
@@ -206,17 +204,16 @@ class _SyncLogTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final durationLabel = _fmtDuration(entry.duration); final durationLabel = _fmtDuration(entry.duration);
final proto = entry.protocol.isEmpty final proto =
? '' entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
: ' · ${entry.protocol.toUpperCase()}';
final theme = Theme.of(context); final theme = Theme.of(context);
final errorColor = theme.colorScheme.error; final errorColor = theme.colorScheme.error;
final subtitleText = entry.isOk final subtitleText = entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel' ? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: entry.isPermanent : entry.isPermanent
? 'Error (permanent) · took $durationLabel' ? 'Error (permanent) · took $durationLabel'
: 'Error · took $durationLabel'; : 'Error · took $durationLabel';
return ExpansionTile( return ExpansionTile(
leading: Icon( leading: Icon(
@@ -341,18 +338,18 @@ class _SyncLogTile extends StatelessWidget {
} }
Widget _row(String label, String value) => Padding( Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 1), padding: const EdgeInsets.symmetric(vertical: 1),
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
width: 180, width: 180,
child: Text( child: Text(
label, label,
style: const TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
), ),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 12))),
],
), ),
Expanded(child: Text(value, style: const TextStyle(fontSize: 12))), );
],
),
);
} }
+5 -9
View File
@@ -101,9 +101,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_bodyFuture = ref _bodyFuture =
.read(emailRepositoryProvider) ref.read(emailRepositoryProvider).getEmailBody(widget.email.id);
.getEmailBody(widget.email.id);
_expanded = widget.isLatest; _expanded = widget.isLatest;
if (widget.email.isSeen == false) { if (widget.email.isSeen == false) {
unawaited( unawaited(
@@ -230,9 +229,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
} }
void _reply(BuildContext context, EmailBody body, {required bool replyAll}) { void _reply(BuildContext context, EmailBody body, {required bool replyAll}) {
final to = widget.email.from.isNotEmpty final to =
? widget.email.from.first.email widget.email.from.isNotEmpty ? widget.email.from.first.email : '';
: '';
final subject = (widget.email.subject?.startsWith('Re:') ?? false) final subject = (widget.email.subject?.startsWith('Re:') ?? false)
? widget.email.subject! ? widget.email.subject!
: 'Re: ${widget.email.subject ?? ''}'; : 'Re: ${widget.email.subject ?? ''}';
@@ -292,9 +290,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
if (!mounted) return; if (!mounted) return;
if (original != null) { if (original != null) {
unawaited( unawaited(
ref ref.read(undoServiceProvider.notifier).pushAction(
.read(undoServiceProvider.notifier)
.pushAction(
UndoAction( UndoAction(
id: DateTime.now().toIso8601String(), id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId, accountId: widget.email.accountId,
+5 -5
View File
@@ -25,7 +25,7 @@ class UndoLogScreen extends ConsumerWidget {
onPressed: history.isEmpty onPressed: history.isEmpty
? null ? null
: () => : () =>
unawaited(ref.read(undoServiceProvider.notifier).clear()), unawaited(ref.read(undoServiceProvider.notifier).clear()),
), ),
], ],
), ),
@@ -59,13 +59,13 @@ class _UndoActionTile extends ConsumerWidget {
action.type == UndoType.delete action.type == UndoType.delete
? Icons.delete_outline ? Icons.delete_outline
: (action.type == UndoType.snooze : (action.type == UndoType.snooze
? Icons.access_time ? Icons.access_time
: Icons.move_to_inbox), : Icons.move_to_inbox),
color: action.type == UndoType.delete color: action.type == UndoType.delete
? Colors.redAccent ? Colors.redAccent
: (action.type == UndoType.snooze : (action.type == UndoType.snooze
? Colors.orangeAccent ? Colors.orangeAccent
: Colors.blueAccent), : Colors.blueAccent),
), ),
title: Text('$subject$extraCount'), title: Text('$subject$extraCount'),
subtitle: Column( subtitle: Column(
+2 -3
View File
@@ -33,9 +33,8 @@ String buildAboutMarkdown({
final gitCommitLine = _gitHash.isNotEmpty final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n' ? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: ''; : '';
final deviceModelLine = deviceModel != null final deviceModelLine =
? '| Device Model | $deviceModel |\n' deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
: '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n' return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n' '| Property | Value |\n'
+3 -5
View File
@@ -37,17 +37,15 @@ class EmailTile extends StatelessWidget {
final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : ''; final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : '';
return ListTile( return ListTile(
leading: leading: leading ??
leading ??
Icon( Icon(
email.isSeen ? Icons.mail_outline : Icons.mail, email.isSeen ? Icons.mail_outline : Icons.mail,
color: email.isSeen ? null : Theme.of(context).colorScheme.primary, color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
), ),
title: Text( title: Text(
sender, sender,
style: email.isSeen style:
? null email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Column( subtitle: Column(
+5 -3
View File
@@ -43,9 +43,11 @@ class FolderDrawer extends ConsumerWidget {
Text( Text(
account?.displayName ?? '', account?.displayName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(context)
fontWeight: FontWeight.bold, .colorScheme
), .onPrimaryContainer,
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
account?.email ?? '', account?.email ?? '',
+11 -13
View File
@@ -16,8 +16,7 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:'; final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
// script-src 'none' blocks page scripts; JS mode stays unrestricted so the // script-src 'none' blocks page scripts; JS mode stays unrestricted so the
// controller can call runJavaScriptReturningResult for height measurement. // controller can call runJavaScriptReturningResult for height measurement.
const cspBase = const cspBase = "default-src 'none'; "
"default-src 'none'; "
"style-src 'unsafe-inline'; " "style-src 'unsafe-inline'; "
"script-src 'none'; " "script-src 'none'; "
"object-src 'none'; " "object-src 'none'; "
@@ -107,9 +106,9 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
} }
String _buildHtml() => buildEmailHtml( String _buildHtml() => buildEmailHtml(
widget.htmlBody, widget.htmlBody,
loadRemoteImages: widget.loadRemoteImages, loadRemoteImages: widget.loadRemoteImages,
); );
Future<void> _measureHeight(String _) async { Future<void> _measureHeight(String _) async {
try { try {
@@ -141,14 +140,13 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
final host = uri.host; final host = uri.host;
final parts = host.split('.'); final parts = host.split('.');
// Bold the registered domain (last two DNS labels) to aid phishing detection. // Bold the registered domain (last two DNS labels) to aid phishing detection.
final boldStart = final boldStart = (parts.length >= 2
(parts.length >= 2 ? host.length -
? host.length - parts.last.length -
parts.last.length - 1 -
1 - parts[parts.length - 2].length
parts[parts.length - 2].length : 0)
: 0) .clamp(0, host.length);
.clamp(0, host.length);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
+51 -44
View File
@@ -16,7 +16,8 @@ Future<imap.ImapClient> _fakeImapConnect(
Account account, Account account,
String username, String username,
String password, String password,
) async => throw const SocketException('fake — no real IMAP server in tests'); ) async =>
throw const SocketException('fake — no real IMAP server in tests');
void main() { void main() {
test( test(
@@ -83,27 +84,27 @@ void main() {
} }
Account _account(String id) => Account( Account _account(String id) => Account(
id: id, id: id,
displayName: 'Account $id', displayName: 'Account $id',
email: '$id@example.com', email: '$id@example.com',
imapHost: 'localhost', imapHost: 'localhost',
imapPort: 143, imapPort: 143,
imapSsl: false, imapSsl: false,
smtpHost: 'localhost', smtpHost: 'localhost',
smtpPort: 25, smtpPort: 25,
smtpSsl: false, smtpSsl: false,
); );
Account _jmapAccount(String id) => Account( Account _jmapAccount(String id) => Account(
id: id, id: id,
displayName: 'Account $id', displayName: 'Account $id',
email: '$id@example.com', email: '$id@example.com',
type: AccountType.jmap, type: AccountType.jmap,
jmapUrl: 'http://localhost:8080/.well-known/jmap', jmapUrl: 'http://localhost:8080/.well-known/jmap',
smtpHost: 'localhost', smtpHost: 'localhost',
smtpPort: 25, smtpPort: 25,
smtpSsl: false, smtpSsl: false,
); );
class _FakeAccounts implements AccountRepository { class _FakeAccounts implements AccountRepository {
_FakeAccounts(this.password); _FakeAccounts(this.password);
@@ -132,16 +133,16 @@ class _FakeAccounts implements AccountRepository {
class _FakeMailboxes implements MailboxRepository { class _FakeMailboxes implements MailboxRepository {
@override @override
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([ Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
Mailbox( Mailbox(
id: '$accountId:INBOX', id: '$accountId:INBOX',
accountId: accountId ?? '', accountId: accountId ?? '',
path: 'INBOX', path: 'INBOX',
name: 'INBOX', name: 'INBOX',
unreadCount: 0, unreadCount: 0,
totalCount: 0, totalCount: 0,
role: 'inbox', role: 'inbox',
), ),
]); ]);
@override @override
Future<int> syncMailboxes(String accountId) async => 0; Future<int> syncMailboxes(String accountId) async => 0;
@@ -158,15 +159,16 @@ class _FakeMailboxes implements MailboxRepository {
String accountId, String accountId,
String name, String name,
String role, String role,
) async => Mailbox( ) async =>
id: '$accountId:$name', Mailbox(
accountId: accountId, id: '$accountId:$name',
path: name, accountId: accountId,
name: name, path: name,
role: role, name: name,
unreadCount: 0, role: role,
totalCount: 0, unreadCount: 0,
); totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
@@ -181,7 +183,8 @@ class _FakeEmails implements EmailRepository {
String a, String a,
String m, { String m, {
int limit = 50, int limit = 50,
}) => Stream.value([]); }) =>
Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
@@ -225,7 +228,8 @@ class _FakeEmails implements EmailRepository {
Future<Email?> findEmailByMessageId( Future<Email?> findEmailByMessageId(
String accountId, String accountId,
String messageId, String messageId,
) async => null; ) async =>
null;
@override @override
Future<String?> deleteEmail(String id) async => null; Future<String?> deleteEmail(String id) async => null;
@@ -243,7 +247,8 @@ class _FakeEmails implements EmailRepository {
Future<String> downloadAttachment( Future<String> downloadAttachment(
String emailId, String emailId,
EmailAttachment attachment, EmailAttachment attachment,
) async => '/tmp/${attachment.filename}'; ) async =>
'/tmp/${attachment.filename}';
@override @override
Future<String> fetchRawRfc822(String emailId) async => ''; Future<String> fetchRawRfc822(String emailId) async => '';
@@ -262,7 +267,8 @@ class _FakeEmails implements EmailRepository {
String? a, String? a,
String q, { String q, {
int limit = 10, int limit = 10,
}) async => []; }) async =>
[];
@override @override
Stream<void> watchJmapPush(String accountId, String password) => Stream<void> watchJmapPush(String accountId, String password) =>
@@ -272,7 +278,8 @@ class _FakeEmails implements EmailRepository {
Future<ReliabilityResult> verifySyncReliability( Future<ReliabilityResult> verifySyncReliability(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
) async => ReliabilityResult.healthy; ) async =>
ReliabilityResult.healthy;
@override @override
Stream<List<FailedMutation>> observeFailedMutations(String accountId) => Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
+2 -3
View File
@@ -246,9 +246,8 @@ void main() {
); );
// Alice and bob each received at least msgCount messages. // Alice and bob each received at least msgCount messages.
final aliceEmails = allEmails final aliceEmails =
.where((e) => e.accountId == 'alice') allEmails.where((e) => e.accountId == 'alice').toList();
.toList();
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList(); final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
expect( expect(
aliceEmails.length, aliceEmails.length,
+3 -7
View File
@@ -138,7 +138,7 @@ void main() {
} }
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
makeRepo() { makeRepo() {
final db = openTestDatabase(); final db = openTestDatabase();
final storage = MapSecureStorage(); final storage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, storage); final accounts = AccountRepositoryImpl(db, storage);
@@ -346,9 +346,7 @@ void main() {
final emailId = emails.first.id; final emailId = emails.first.id;
// Simulate a legacy row with no cachedAt. // Simulate a legacy row with no cachedAt.
await r.db await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
.into(r.db.emailBodies)
.insertOnConflictUpdate(
EmailBodiesCompanion.insert( EmailBodiesCompanion.insert(
emailId: emailId, emailId: emailId,
textBody: const Value('stale text'), textBody: const Value('stale text'),
@@ -374,9 +372,7 @@ void main() {
final emailId = emails.first.id; final emailId = emails.first.id;
// Simulate a row cached 8 days ago. // Simulate a row cached 8 days ago.
await r.db await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
.into(r.db.emailBodies)
.insertOnConflictUpdate(
EmailBodiesCompanion.insert( EmailBodiesCompanion.insert(
emailId: emailId, emailId: emailId,
textBody: const Value('old text'), textBody: const Value('old text'),
+21 -27
View File
@@ -107,8 +107,7 @@ void main() {
AccountRepositoryImpl accounts, AccountRepositoryImpl accounts,
EmailRepositoryImpl emails, EmailRepositoryImpl emails,
MailboxRepositoryImpl mailboxes, MailboxRepositoryImpl mailboxes,
}) }) makeRepo() {
makeRepo() {
final db = openTestDatabase(); final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final emails = EmailRepositoryImpl( final emails = EmailRepositoryImpl(
@@ -128,13 +127,12 @@ void main() {
) async { ) async {
await accounts.addAccount(account, userPass); await accounts.addAccount(account, userPass);
await mailboxes.syncMailboxes('test-jmap'); await mailboxes.syncMailboxes('test-jmap');
final row = final row = await (db.select(db.mailboxes)
await (db.select(db.mailboxes) ..where(
..where( (t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'),
(t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'), )
) ..limit(1))
..limit(1)) .getSingleOrNull();
.getSingleOrNull();
if (row == null) throw StateError('INBOX not found after syncMailboxes'); if (row == null) throw StateError('INBOX not found after syncMailboxes');
return row.path; return row.path;
} }
@@ -272,21 +270,18 @@ void main() {
); );
// A sent copy should appear in the Sent mailbox. // A sent copy should appear in the Sent mailbox.
final sentRow = final sentRow = await (r.db.select(r.db.mailboxes)
await (r.db.select(r.db.mailboxes) ..where(
..where( (t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
(t) => )
t.accountId.equals('test-jmap') & t.role.equals('sent'), ..limit(1))
) .getSingleOrNull();
..limit(1))
.getSingleOrNull();
final sentId = sentRow?.path; final sentId = sentRow?.path;
if (sentId != null) { if (sentId != null) {
await r.emails.syncEmails('test-jmap', sentId); await r.emails.syncEmails('test-jmap', sentId);
final sentEmails = await r.emails final sentEmails =
.observeEmails('test-jmap', sentId) await r.emails.observeEmails('test-jmap', sentId).first;
.first;
expect(sentEmails.any((e) => e.subject == subject), isTrue); expect(sentEmails.any((e) => e.subject == subject), isTrue);
} else { } else {
// If no Sent mailbox exists, just verify sendEmail didn't throw. // If no Sent mailbox exists, just verify sendEmail didn't throw.
@@ -353,13 +348,12 @@ void main() {
await r.emails.syncEmails('test-jmap', inboxId); await r.emails.syncEmails('test-jmap', inboxId);
// Find a destination mailbox (Trash). // Find a destination mailbox (Trash).
final trashRow = final trashRow = await (r.db.select(r.db.mailboxes)
await (r.db.select(r.db.mailboxes) ..where(
..where( (t) => t.accountId.equals('test-jmap') & t.role.equals('trash'),
(t) => t.accountId.equals('test-jmap') & t.role.equals('trash'), )
) ..limit(1))
..limit(1)) .getSingleOrNull();
.getSingleOrNull();
if (trashRow == null) { if (trashRow == null) {
markTestSkipped('No trash mailbox found on this Stalwart instance'); markTestSkipped('No trash mailbox found on this Stalwart instance');
return; return;
@@ -76,8 +76,7 @@ void main() {
AppDatabase db, AppDatabase db,
AccountRepositoryImpl accounts, AccountRepositoryImpl accounts,
MailboxRepositoryImpl mailboxes, MailboxRepositoryImpl mailboxes,
}) }) makeRepo() {
makeRepo() {
final db = openTestDatabase(); final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl( final mailboxes = MailboxRepositoryImpl(
+1 -3
View File
@@ -107,9 +107,7 @@ void main() {
'verifySyncReliability identifies extra local emails (missing on server)', 'verifySyncReliability identifies extra local emails (missing on server)',
() async { () async {
// 1. Manually insert a row into local DB that doesn't exist on server // 1. Manually insert a row into local DB that doesn't exist on server
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'test:999', id: 'test:999',
accountId: 'test', accountId: 'test',
+33 -28
View File
@@ -78,7 +78,8 @@ class FakeEmailRepository implements EmailRepository {
String a, String a,
String m, { String m, {
int limit = 50, int limit = 50,
}) => Stream.value([]); }) =>
Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
@@ -113,7 +114,8 @@ class FakeEmailRepository implements EmailRepository {
Future<Email?> findEmailByMessageId( Future<Email?> findEmailByMessageId(
String accountId, String accountId,
String messageId, String messageId,
) async => null; ) async =>
null;
@override @override
Future<String?> deleteEmail(String id) async => null; Future<String?> deleteEmail(String id) async => null;
@@ -138,7 +140,8 @@ class FakeEmailRepository implements EmailRepository {
String? a, String? a,
String q, { String q, {
int limit = 10, int limit = 10,
}) async => []; }) async =>
[];
@override @override
Stream<void> watchJmapPush(String a, String p) => const Stream.empty(); Stream<void> watchJmapPush(String a, String p) => const Stream.empty();
@override @override
@@ -153,7 +156,8 @@ class FakeEmailRepository implements EmailRepository {
Future<ReliabilityResult> verifySyncReliability( Future<ReliabilityResult> verifySyncReliability(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
) async => ReliabilityResult.healthy; ) async =>
ReliabilityResult.healthy;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@@ -201,16 +205,16 @@ class FakeSyncLogRepository implements SyncLogRepository {
class FakeMailboxRepositoryWithInbox implements MailboxRepository { class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@override @override
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([ Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
const Mailbox( const Mailbox(
id: '1:INBOX', id: '1:INBOX',
accountId: '1', accountId: '1',
path: 'INBOX', path: 'INBOX',
name: 'INBOX', name: 'INBOX',
unreadCount: 0, unreadCount: 0,
totalCount: 0, totalCount: 0,
role: 'inbox', role: 'inbox',
), ),
]); ]);
@override @override
Future<int> syncMailboxes(String id) async => 1; Future<int> syncMailboxes(String id) async => 1;
@override @override
@@ -222,15 +226,16 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
String accountId, String accountId,
String name, String name,
String role, String role,
) async => Mailbox( ) async =>
id: '$accountId:$name', Mailbox(
accountId: accountId, id: '$accountId:$name',
path: name, accountId: accountId,
name: name, path: name,
role: role, name: name,
unreadCount: 0, role: role,
totalCount: 0, unreadCount: 0,
); totalCount: 0,
);
} }
class _AccountRepositoryWithMissingPlugin implements AccountRepository { class _AccountRepositoryWithMissingPlugin implements AccountRepository {
@@ -248,11 +253,11 @@ class _AccountRepositoryWithMissingPlugin implements AccountRepository {
@override @override
Future<String> getPassword(String accountId) => Future.error( Future<String> getPassword(String accountId) => Future.error(
MissingPluginException( MissingPluginException(
'No implementation found for method read on channel ' 'No implementation found for method read on channel '
'plugins.it.nomads.com/flutter_secure_storage', 'plugins.it.nomads.com/flutter_secure_storage',
), ),
); );
@override @override
Future<void> addAccount(Account account, String password) async {} Future<void> addAccount(Account account, String password) async {}
+5 -15
View File
@@ -40,9 +40,7 @@ Future<String> _insertInboxEmail(
String from = 'sender@example.com', String from = 'sender@example.com',
String mailboxPath = 'INBOX', String mailboxPath = 'INBOX',
}) async { }) async {
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: id, id: id,
accountId: _account.id, accountId: _account.id,
@@ -59,9 +57,7 @@ Future<String> _insertInboxEmail(
), ),
); );
// Insert a thread row so _updateThread does not throw. // Insert a thread row so _updateThread does not throw.
await db await db.into(db.threads).insertOnConflictUpdate(
.into(db.threads)
.insertOnConflictUpdate(
ThreadsCompanion.insert( ThreadsCompanion.insert(
id: id, id: id,
accountId: _account.id, accountId: _account.id,
@@ -75,9 +71,7 @@ Future<String> _insertInboxEmail(
/// Creates an active Sieve script for the test account. /// Creates an active Sieve script for the test account.
Future<void> _insertSieveScript(AppDatabase db, String content) async { Future<void> _insertSieveScript(AppDatabase db, String content) async {
await db await db.into(db.localSieveScripts).insert(
.into(db.localSieveScripts)
.insert(
LocalSieveScriptsCompanion.insert( LocalSieveScriptsCompanion.insert(
accountId: _account.id, accountId: _account.id,
name: 'test-script', name: 'test-script',
@@ -224,9 +218,7 @@ if header :contains "subject" ["SPAM"] {
} }
'''); ''');
// Insert without messageId. // Insert without messageId.
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'sieve-acc:2', id: 'sieve-acc:2',
accountId: _account.id, accountId: _account.id,
@@ -236,9 +228,7 @@ if header :contains "subject" ["SPAM"] {
receivedAt: DateTime.now(), receivedAt: DateTime.now(),
), ),
); );
await db await db.into(db.threads).insertOnConflictUpdate(
.into(db.threads)
.insertOnConflictUpdate(
ThreadsCompanion.insert( ThreadsCompanion.insert(
id: 'sieve-acc:2', id: 'sieve-acc:2',
accountId: _account.id, accountId: _account.id,
+1 -2
View File
@@ -59,8 +59,7 @@ void main() {
test('leaves HTML unchanged when there are no inline parts', () { test('leaves HTML unchanged when there are no inline parts', () {
// A plain text-only message. // A plain text-only message.
const plainMime = const plainMime = 'MIME-Version: 1.0\r\n'
'MIME-Version: 1.0\r\n'
'Content-Type: text/plain\r\n' 'Content-Type: text/plain\r\n'
'\r\n' '\r\n'
'Hello'; 'Hello';
+15 -17
View File
@@ -23,8 +23,7 @@ const _jmapAccount = Account(
jmapUrl: 'https://example.com/jmap/session', jmapUrl: 'https://example.com/jmap/session',
); );
const _jmapSessionJson = const _jmapSessionJson = '{'
'{'
'"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}},' '"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}},'
'"accounts":{},"primaryAccounts":{},"username":"alice@example.com",' '"accounts":{},"primaryAccounts":{},"username":"alice@example.com",'
'"apiUrl":"https://example.com/jmap/","downloadUrl":"","uploadUrl":"","state":"0"' '"apiUrl":"https://example.com/jmap/","downloadUrl":"","uploadUrl":"","state":"0"'
@@ -117,15 +116,14 @@ void main() {
MockClient((_) async => http.Response('', 200)), MockClient((_) async => http.Response('', 200)),
imapConnect: (_, __, ___) async => FakeImapClient(), imapConnect: (_, __, ___) async => FakeImapClient(),
smtpConnect: (_, __, ___) async => FakeSmtpClient(), smtpConnect: (_, __, ___) async => FakeSmtpClient(),
manageSieveConnect: manageSieveConnect: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async {
}) async { sieveCalled = true;
sieveCalled = true; throw Exception('should not be called');
throw Exception('should not be called'); },
},
); );
await svc.testConnection(_imapAccount, 'pw'); await svc.testConnection(_imapAccount, 'pw');
expect(sieveCalled, false); expect(sieveCalled, false);
@@ -144,12 +142,12 @@ void main() {
MockClient((_) async => http.Response('', 200)), MockClient((_) async => http.Response('', 200)),
imapConnect: (_, __, ___) async => FakeImapClient(), imapConnect: (_, __, ___) async => FakeImapClient(),
smtpConnect: (_, __, ___) async => FakeSmtpClient(), smtpConnect: (_, __, ___) async => FakeSmtpClient(),
manageSieveConnect: manageSieveConnect: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async =>
}) async => throw Exception('sieve boom'), throw Exception('sieve boom'),
); );
expect( expect(
() => svc.testConnection(accountWithSieve, 'pw'), () => svc.testConnection(accountWithSieve, 'pw'),
+2 -2
View File
@@ -8,8 +8,8 @@ import 'package:test/test.dart';
// Mirrors the encoding logic in EmailRepositoryImpl so we can test it // Mirrors the encoding logic in EmailRepositoryImpl so we can test it
// independently without spinning up a database. // independently without spinning up a database.
String encodeAddresses(List<EmailAddress> addresses) => jsonEncode( String encodeAddresses(List<EmailAddress> addresses) => jsonEncode(
addresses.map((a) => {'name': a.name, 'email': a.email}).toList(), addresses.map((a) => {'name': a.name, 'email': a.email}).toList(),
); );
List<EmailAddress> decodeAddresses(String json) { List<EmailAddress> decodeAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>; final list = jsonDecode(json) as List<dynamic>;
@@ -34,9 +34,7 @@ void main() {
}); });
test('cancelPendingChange removes an unattempted change', () async { test('cancelPendingChange removes an unattempted change', () async {
await db await db.into(db.pendingChanges).insert(
.into(db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc1', accountId: 'acc1',
resourceType: 'Email', resourceType: 'Email',
@@ -55,9 +53,7 @@ void main() {
}); });
test('cancelPendingChange does not remove attempted changes', () async { test('cancelPendingChange does not remove attempted changes', () async {
await db await db.into(db.pendingChanges).insert(
.into(db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc1', accountId: 'acc1',
resourceType: 'Email', resourceType: 'Email',
@@ -78,9 +74,7 @@ void main() {
test('cancelPendingChange only removes the latest matching change', () async { test('cancelPendingChange only removes the latest matching change', () async {
final now = DateTime.now(); final now = DateTime.now();
await db await db.into(db.pendingChanges).insert(
.into(db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc1', accountId: 'acc1',
resourceType: 'Email', resourceType: 'Email',
@@ -90,9 +84,7 @@ void main() {
createdAt: now, createdAt: now,
), ),
); );
await db await db.into(db.pendingChanges).insert(
.into(db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc1', accountId: 'acc1',
resourceType: 'Email', resourceType: 'Email',
@@ -186,9 +186,7 @@ class _EmailRepositoryImplContract extends EmailRepositoryContract {
bool isFlagged = false, bool isFlagged = false,
DateTime? receivedAt, DateTime? receivedAt,
}) async { }) async {
await _db await _db.into(_db.emails).insert(
.into(_db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: id, id: id,
accountId: _account.id, accountId: _account.id,
+223 -329
View File
@@ -68,25 +68,26 @@ Map<String, dynamic> _emailGetResponse({
required String state, required String state,
required List<Map<String, dynamic>> list, required List<Map<String, dynamic>> list,
int? total, int? total,
}) => { }) =>
'sessionState': 'sess1', {
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'Email/query', [
{ 'Email/query',
'accountId': 'acct1', {
'ids': list.map((e) => e['id']).toList(), 'accountId': 'acct1',
'total': total ?? list.length, 'ids': list.map((e) => e['id']).toList(),
}, 'total': total ?? list.length,
'0', },
], '0',
[ ],
'Email/get', [
{'accountId': 'acct1', 'state': state, 'list': list}, 'Email/get',
'1', {'accountId': 'acct1', 'state': state, 'list': list},
], '1',
], ],
}; ],
};
Map<String, dynamic> _emailChangesResponse({ Map<String, dynamic> _emailChangesResponse({
required String oldState, required String oldState,
@@ -94,38 +95,40 @@ Map<String, dynamic> _emailChangesResponse({
List<String> created = const [], List<String> created = const [],
List<String> updated = const [], List<String> updated = const [],
List<String> destroyed = const [], List<String> destroyed = const [],
}) => { }) =>
'sessionState': 'sess1', {
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'Email/changes', [
{ 'Email/changes',
'accountId': 'acct1', {
'oldState': oldState, 'accountId': 'acct1',
'newState': newState, 'oldState': oldState,
'hasMoreChanges': false, 'newState': newState,
'created': created, 'hasMoreChanges': false,
'updated': updated, 'created': created,
'destroyed': destroyed, 'updated': updated,
}, 'destroyed': destroyed,
'0', },
], '0',
], ],
}; ],
};
Map<String, dynamic> _emailGetOnly({ Map<String, dynamic> _emailGetOnly({
required String state, required String state,
required List<Map<String, dynamic>> list, required List<Map<String, dynamic>> list,
}) => { }) =>
'sessionState': 'sess1', {
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'Email/get', [
{'accountId': 'acct1', 'state': state, 'list': list}, 'Email/get',
'1', {'accountId': 'acct1', 'state': state, 'list': list},
], '1',
], ],
}; ],
};
Map<String, dynamic> _jmapEmail({ Map<String, dynamic> _jmapEmail({
required String id, required String id,
@@ -133,24 +136,25 @@ Map<String, dynamic> _jmapEmail({
String subject = 'Hello', String subject = 'Hello',
bool seen = false, bool seen = false,
String? threadId, String? threadId,
}) => { }) =>
'id': id, {
'mailboxIds': {mailboxId: true}, 'id': id,
'subject': subject, 'mailboxIds': {mailboxId: true},
'sentAt': '2024-01-01T10:00:00Z', 'subject': subject,
'receivedAt': '2024-01-01T10:00:01Z', 'sentAt': '2024-01-01T10:00:00Z',
'from': [ 'receivedAt': '2024-01-01T10:00:01Z',
{'name': 'Sender', 'email': 'sender@example.com'}, 'from': [
], {'name': 'Sender', 'email': 'sender@example.com'},
'to': [ ],
{'name': 'Alice', 'email': 'alice@example.com'}, 'to': [
], {'name': 'Alice', 'email': 'alice@example.com'},
'cc': [], ],
'keywords': seen ? {r'$seen': true} : <String, dynamic>{}, 'cc': [],
'hasAttachment': false, 'keywords': seen ? {r'$seen': true} : <String, dynamic>{},
'preview': 'Hello world', 'hasAttachment': false,
'threadId': threadId, 'preview': 'Hello world',
}; 'threadId': threadId,
};
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) => Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('IMAP unavailable in unit tests')); Future.error(UnsupportedError('IMAP unavailable in unit tests'));
@@ -159,7 +163,7 @@ Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('SMTP unavailable in unit tests')); Future.error(UnsupportedError('SMTP unavailable in unit tests'));
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
_makeRepos({ _makeRepos({
http.Client? httpClient, http.Client? httpClient,
Future<imap.ImapClient> Function(Account, String, String)? imapConnect, Future<imap.ImapClient> Function(Account, String, String)? imapConnect,
Future<imap.SmtpClient> Function(Account, String, String)? smtpConnect, Future<imap.SmtpClient> Function(Account, String, String)? smtpConnect,
@@ -199,9 +203,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:42', id: 'acc-1:42',
accountId: 'acc-1', accountId: 'acc-1',
@@ -221,9 +223,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:7', id: 'acc-1:7',
accountId: 'acc-1', accountId: 'acc-1',
@@ -247,9 +247,7 @@ void main() {
(3, DateTime(2024, 3)), (3, DateTime(2024, 3)),
(2, DateTime(2024, 2)), (2, DateTime(2024, 2)),
]) { ]) {
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:$uid', id: 'acc-1:$uid',
accountId: 'acc-1', accountId: 'acc-1',
@@ -276,9 +274,7 @@ void main() {
test('getEmailBody propagates IMAP error when not cached', () async { test('getEmailBody propagates IMAP error when not cached', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -296,9 +292,7 @@ void main() {
test('getEmailBody returns cached body without IMAP call', () async { test('getEmailBody returns cached body without IMAP call', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -307,9 +301,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emailBodies).insert(
.into(r.db.emailBodies)
.insert(
EmailBodiesCompanion.insert( EmailBodiesCompanion.insert(
emailId: 'acc-1:1', emailId: 'acc-1:1',
textBody: const Value('Hello'), textBody: const Value('Hello'),
@@ -330,9 +322,7 @@ void main() {
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
final now = DateTime.now(); final now = DateTime.now();
await r.db await r.db.into(r.db.threads).insert(
.into(r.db.threads)
.insert(
ThreadsCompanion.insert( ThreadsCompanion.insert(
id: 'tid1', id: 'tid1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -359,9 +349,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -371,9 +359,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:2', id: 'acc-1:2',
accountId: 'acc-1', accountId: 'acc-1',
@@ -384,9 +370,8 @@ void main() {
), ),
); );
final emails = await r.emails final emails =
.observeEmailsInThread('acc-1', 'INBOX', 'tid1') await r.emails.observeEmailsInThread('acc-1', 'INBOX', 'tid1').first;
.first;
expect(emails, hasLength(2)); expect(emails, hasLength(2));
expect(emails.map((e) => e.id).toSet(), {'acc-1:1', 'acc-1:2'}); expect(emails.map((e) => e.id).toSet(), {'acc-1:1', 'acc-1:2'});
}); });
@@ -401,9 +386,7 @@ void main() {
'pw', 'pw',
); );
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -413,9 +396,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-2:1', id: 'acc-2:1',
accountId: 'acc-2', accountId: 'acc-2',
@@ -444,9 +425,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -456,9 +435,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:2', id: 'acc-1:2',
accountId: 'acc-1', accountId: 'acc-1',
@@ -486,9 +463,7 @@ void main() {
final newer = DateTime(2024, 6); final newer = DateTime(2024, 6);
// Two emails — older one has alice@, newer one has bob@. // Two emails — older one has alice@, newer one has bob@.
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:old', id: 'acc-1:old',
accountId: 'acc-1', accountId: 'acc-1',
@@ -500,9 +475,7 @@ void main() {
), ),
), ),
); );
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:new', id: 'acc-1:new',
accountId: 'acc-1', accountId: 'acc-1',
@@ -531,9 +504,7 @@ void main() {
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -559,9 +530,7 @@ void main() {
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -585,9 +554,7 @@ void main() {
test('setFlag flagged=true enqueues flag_flagged change', () async { test('setFlag flagged=true enqueues flag_flagged change', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -610,9 +577,7 @@ void main() {
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -636,9 +601,7 @@ void main() {
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -665,9 +628,7 @@ void main() {
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -691,9 +652,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
// _makeRepos uses _noImapConnect which throws UnsupportedError // _makeRepos uses _noImapConnect which throws UnsupportedError
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'Email', resourceType: 'Email',
@@ -714,9 +673,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
// Pre-seed a flag_seen at attempts=4 // Pre-seed a flag_seen at attempts=4
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: _account.id, accountId: _account.id,
resourceType: 'Email', resourceType: 'Email',
@@ -748,9 +705,7 @@ void main() {
final spy = SnoozeSpyImapClient(); final spy = SnoozeSpyImapClient();
final r = _makeRepos(imapConnect: (_, __, ___) async => spy); final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -759,9 +714,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'Email', resourceType: 'Email',
@@ -793,9 +746,7 @@ void main() {
test('snoozeEmail enqueues snooze change and updates local DB', () async { test('snoozeEmail enqueues snooze change and updates local DB', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -823,9 +774,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
// Seed Inbox mailbox // Seed Inbox mailbox
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:INBOX', id: 'acc-1:INBOX',
accountId: 'acc-1', accountId: 'acc-1',
@@ -836,9 +785,7 @@ void main() {
); );
final past = DateTime.now().subtract(const Duration(hours: 1)); final past = DateTime.now().subtract(const Duration(hours: 1));
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -867,65 +814,64 @@ void main() {
http.Client mockBodyClient({ http.Client mockBodyClient({
String text = 'Hello from JMAP', String text = 'Hello from JMAP',
String html = '<p>Hello from JMAP</p>', String html = '<p>Hello from JMAP</p>',
}) => MockClient((req) async { }) =>
if (req.url.path.contains('well-known')) { MockClient((req) async {
return http.Response( if (req.url.path.contains('well-known')) {
jsonEncode({ return http.Response(
'apiUrl': 'https://jmap.example.com/api/', jsonEncode({
'accounts': { 'apiUrl': 'https://jmap.example.com/api/',
'acct1': {'name': 'alice@example.com', 'isPersonal': true}, 'accounts': {
}, 'acct1': {'name': 'alice@example.com', 'isPersonal': true},
'primaryAccounts': { },
'urn:ietf:params:jmap:core': 'acct1', 'primaryAccounts': {
'urn:ietf:params:jmap:mail': 'acct1', 'urn:ietf:params:jmap:core': 'acct1',
}, 'urn:ietf:params:jmap:mail': 'acct1',
'capabilities': {}, },
'username': 'alice@example.com', 'capabilities': {},
'state': 'sess1', 'username': 'alice@example.com',
}), 'state': 'sess1',
200, }),
); 200,
} );
return http.Response( }
jsonEncode({ return http.Response(
'sessionState': 'sess1', jsonEncode({
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'Email/get', [
{ 'Email/get',
'accountId': 'acct1',
'state': 'es1',
'list': [
{ {
'id': 'e1', 'accountId': 'acct1',
'textBody': [ 'state': 'es1',
{'partId': '1', 'type': 'text/plain'}, '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': [],
},
], ],
'htmlBody': [
{'partId': '2', 'type': 'text/html'},
],
'bodyValues': {
'1': {'value': text, 'isTruncated': false},
'2': {'value': html, 'isTruncated': false},
},
'attachments': [],
}, },
'0',
], ],
}, ],
'0', }),
], 200,
], );
}), });
200,
);
});
test('fetches body via JMAP Email/get and caches it', () async { test('fetches body via JMAP Email/get and caches it', () async {
final r = _makeRepos(httpClient: mockBodyClient()); final r = _makeRepos(httpClient: mockBodyClient());
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e1', id: 'jmap-1:e1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -994,9 +940,7 @@ void main() {
}), }),
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e1', id: 'jmap-1:e1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -1075,9 +1019,7 @@ void main() {
}), }),
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e1', id: 'jmap-1:e1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -1107,9 +1049,7 @@ void main() {
test('mimeTree is null when bodyStructure is absent', () async { test('mimeTree is null when bodyStructure is absent', () async {
final r = _makeRepos(httpClient: mockBodyClient()); final r = _makeRepos(httpClient: mockBodyClient());
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e1', id: 'jmap-1:e1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -1188,9 +1128,7 @@ void main() {
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
// Pre-populate // Pre-populate
await r.db await r.db.into(r.db.emails).insertOnConflictUpdate(
.into(r.db.emails)
.insertOnConflictUpdate(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e1', id: 'jmap-1:e1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -1200,9 +1138,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emails).insertOnConflictUpdate(
.into(r.db.emails)
.insertOnConflictUpdate(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e2', id: 'jmap-1:e2',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -1212,9 +1148,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1241,9 +1175,7 @@ void main() {
), ),
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1298,9 +1230,7 @@ void main() {
AccountRepositoryImpl accounts, AccountRepositoryImpl accounts,
) async { ) async {
await accounts.addAccount(_jmapAccount, 'pw'); await accounts.addAccount(_jmapAccount, 'pw');
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'jmap-1:e1', id: 'jmap-1:e1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -1416,9 +1346,7 @@ void main() {
String payload = '{"seen":true}', String payload = '{"seen":true}',
}) async { }) async {
await accounts.addAccount(_jmapAccount, 'pw'); await accounts.addAccount(_jmapAccount, 'pw');
await db await db.into(db.pendingChanges).insert(
.into(db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1532,9 +1460,7 @@ void main() {
final r = _makeRepos(httpClient: client); final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1542,9 +1468,7 @@ void main() {
syncedAt: DateTime.now(), syncedAt: DateTime.now(),
), ),
); );
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1605,9 +1529,7 @@ void main() {
final r = _makeRepos(httpClient: client); final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1615,9 +1537,7 @@ void main() {
syncedAt: DateTime.now(), syncedAt: DateTime.now(),
), ),
); );
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1682,9 +1602,7 @@ void main() {
final r = _makeRepos(httpClient: client); final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1706,9 +1624,7 @@ void main() {
final r = _makeRepos(httpClient: mockFlush(500)); final r = _makeRepos(httpClient: mockFlush(500));
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
// Seed a change already at attempts=4 (one below the eviction threshold) // Seed a change already at attempts=4 (one below the eviction threshold)
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Email', resourceType: 'Email',
@@ -1813,12 +1729,10 @@ void main() {
expect(firstCall, 'Mailbox/set'); expect(firstCall, 'Mailbox/set');
// Second call should be Email/set using the newly created mailbox ID. // Second call should be Email/set using the newly created mailbox ID.
final secondCallArgs = final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
((capturedBodies[1]['methodCalls'] as List).first as List)[1] as List)[1] as Map<String, dynamic>;
as Map<String, dynamic>; final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
final update = as Map<String, dynamic>;
(secondCallArgs['update'] as Map<String, dynamic>)['e1']
as Map<String, dynamic>;
expect(update['mailboxIds/mbx-snoozed'], true); expect(update['mailboxIds/mbx-snoozed'], true);
}, },
); );
@@ -1853,30 +1767,31 @@ void main() {
required String mailboxId, required String mailboxId,
String? textContent, String? textContent,
String? htmlContent, String? htmlContent,
}) => { }) =>
..._jmapEmail(id: id, mailboxId: mailboxId), {
'textBody': [ ..._jmapEmail(id: id, mailboxId: mailboxId),
if (textContent != null) {'partId': 'text1', 'type': 'text/plain'}, 'textBody': [
], if (textContent != null) {'partId': 'text1', 'type': 'text/plain'},
'htmlBody': [ ],
if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'}, 'htmlBody': [
], if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'},
'bodyValues': { ],
if (textContent != null) 'bodyValues': {
'text1': { if (textContent != null)
'value': textContent, 'text1': {
'isEncodingProblem': false, 'value': textContent,
'isTruncated': false, 'isEncodingProblem': false,
'isTruncated': false,
},
if (htmlContent != null)
'html1': {
'value': htmlContent,
'isEncodingProblem': false,
'isTruncated': false,
},
}, },
if (htmlContent != null) 'attachments': [],
'html1': { };
'value': htmlContent,
'isEncodingProblem': false,
'isTruncated': false,
},
},
'attachments': [],
};
test('full sync caches bodies when bodyValues are present', () async { test('full sync caches bodies when bodyValues are present', () async {
final r = _makeRepos( final r = _makeRepos(
@@ -2164,9 +2079,7 @@ void main() {
final r = _makeRepos(httpClient: client); final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
// Seed a Sent mailbox with role='sent' // Seed a Sent mailbox with role='sent'
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'jmap-1:sentMbx', id: 'jmap-1:sentMbx',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -2267,9 +2180,7 @@ void main() {
// no IMAP connection was made. // no IMAP connection was made.
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -2278,9 +2189,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
.into(r.db.emailBodies)
.insertOnConflictUpdate(
EmailBodiesCompanion.insert( EmailBodiesCompanion.insert(
emailId: 'acc-1:1', emailId: 'acc-1:1',
textBody: const Value('cached text'), textBody: const Value('cached text'),
@@ -2300,9 +2209,7 @@ void main() {
test('observeFailedMutations emits only rows with lastError set', () async { test('observeFailedMutations emits only rows with lastError set', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'email', resourceType: 'email',
@@ -2313,9 +2220,7 @@ void main() {
lastError: const Value('network error'), lastError: const Value('network error'),
), ),
); );
await r.db await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'email', resourceType: 'email',
@@ -2338,9 +2243,7 @@ void main() {
test('discardMutation removes the row', () async { test('discardMutation removes the row', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
final rowId = await r.db final rowId = await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'email', resourceType: 'email',
@@ -2362,9 +2265,7 @@ void main() {
test('retryMutation resets attempts and clears lastError', () async { test('retryMutation resets attempts and clears lastError', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
final rowId = await r.db final rowId = await r.db.into(r.db.pendingChanges).insert(
.into(r.db.pendingChanges)
.insert(
PendingChangesCompanion.insert( PendingChangesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'email', resourceType: 'email',
@@ -2391,9 +2292,7 @@ void main() {
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
@@ -2412,9 +2311,8 @@ void main() {
expect(changes, hasLength(2)); expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move')); expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations = changes final destinations =
.map((c) => (jsonDecode(c.payload) as Map)['dest']) changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
.toSet();
expect(destinations, containsAll(['Archive', 'Trash'])); expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
@@ -2467,9 +2365,7 @@ void main() {
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
// Pre-seed two emails from the old server epoch (uidValidity=123). // Pre-seed two emails from the old server epoch (uidValidity=123).
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:1', id: 'acc-1:1',
accountId: 'acc-1', accountId: 'acc-1',
@@ -2478,9 +2374,7 @@ void main() {
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.db await r.db.into(r.db.emails).insert(
.into(r.db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:2', id: 'acc-1:2',
accountId: 'acc-1', accountId: 'acc-1',
@@ -2492,9 +2386,7 @@ void main() {
// Seed an IMAP checkpoint with the old uidValidity so the code detects // Seed an IMAP checkpoint with the old uidValidity so the code detects
// a mismatch and triggers a full re-sync. // a mismatch and triggers a full re-sync.
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'acc-1', accountId: 'acc-1',
resourceType: 'IMAP:INBOX', resourceType: 'IMAP:INBOX',
@@ -2510,13 +2402,13 @@ void main() {
expect(remaining, isEmpty); expect(remaining, isEmpty);
// Checkpoint must be updated to the new uidValidity. // Checkpoint must be updated to the new uidValidity.
final stateRow = final stateRow = await (r.db.select(r.db.syncStates)
await (r.db.select(r.db.syncStates)..where( ..where(
(t) => (t) =>
t.accountId.equals('acc-1') & t.accountId.equals('acc-1') &
t.resourceType.equals('IMAP:INBOX'), t.resourceType.equals('IMAP:INBOX'),
)) ))
.getSingleOrNull(); .getSingleOrNull();
expect(stateRow, isNotNull); expect(stateRow, isNotNull);
final state = jsonDecode(stateRow!.state) as Map<String, dynamic>; final state = jsonDecode(stateRow!.state) as Map<String, dynamic>;
expect(state['uidValidity'], 456); expect(state['uidValidity'], 456);
@@ -2535,20 +2427,22 @@ class _FakeImapClientUidValidity extends FakeImapClient {
String path, { String path, {
bool enableCondStore = false, bool enableCondStore = false,
imap.QResyncParameters? qresync, imap.QResyncParameters? qresync,
}) async => imap.Mailbox( }) async =>
encodedName: path, imap.Mailbox(
encodedPath: path, encodedName: path,
flags: [], encodedPath: path,
pathSeparator: '/', flags: [],
uidValidity: _uidValidity, pathSeparator: '/',
); uidValidity: _uidValidity,
);
@override @override
Future<imap.SearchImapResult> uidSearchMessages({ Future<imap.SearchImapResult> uidSearchMessages({
String searchCriteria = 'ALL', String searchCriteria = 'ALL',
List<imap.ReturnOption>? returnOptions, List<imap.ReturnOption>? returnOptions,
Duration? responseTimeout, Duration? responseTimeout,
}) async => imap.SearchImapResult(); }) async =>
imap.SearchImapResult();
} }
// ── SSE test helper ────────────────────────────────────────────────────────── // ── SSE test helper ──────────────────────────────────────────────────────────
+9 -7
View File
@@ -24,11 +24,11 @@ class SnoozeSpyImapClient extends FakeImapClient {
String? movedToMailbox; String? movedToMailbox;
imap.Mailbox _fakeMailbox(String path) => imap.Mailbox( imap.Mailbox _fakeMailbox(String path) => imap.Mailbox(
encodedName: path, encodedName: path,
encodedPath: path, encodedPath: path,
pathSeparator: '/', pathSeparator: '/',
flags: [], flags: [],
); );
@override @override
Future<imap.Mailbox> selectMailboxByPath( Future<imap.Mailbox> selectMailboxByPath(
@@ -53,7 +53,8 @@ class SnoozeSpyImapClient extends FakeImapClient {
imap.StoreAction? action, imap.StoreAction? action,
bool? silent, bool? silent,
int? unchangedSinceModSequence, int? unchangedSinceModSequence,
}) async => imap.StoreImapResult(); }) async =>
imap.StoreImapResult();
@override @override
Future<imap.GenericImapResult> uidMove( Future<imap.GenericImapResult> uidMove(
@@ -71,7 +72,8 @@ class SnoozeSpyImapClient extends FakeImapClient {
String? fetchContentDefinition, { String? fetchContentDefinition, {
int? changedSinceModSequence, int? changedSinceModSequence,
Duration? responseTimeout, Duration? responseTimeout,
}) async => const imap.FetchImapResult([], null); }) async =>
const imap.FetchImapResult([], null);
} }
/// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService. /// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService.
+1 -2
View File
@@ -56,8 +56,7 @@ void main() {
}); });
test('real-world HTML email snippet', () { test('real-world HTML email snippet', () {
const html = const html = '<p>Hello <b>Alice</b>,</p>'
'<p>Hello <b>Alice</b>,</p>'
'<p>Please find the invoice attached.</p>' '<p>Please find the invoice attached.</p>'
'<p>Best regards,<br/>Bob</p>'; '<p>Best regards,<br/>Bob</p>';
final result = htmlToPlain(html); final result = htmlToPlain(html);
+17 -17
View File
@@ -11,23 +11,23 @@ const _apiUrl = 'https://jmap.example.com/api/';
const _accountId = 'u1'; const _accountId = 'u1';
Map<String, dynamic> _sessionBody({String? apiUrl, String? accountId}) => { Map<String, dynamic> _sessionBody({String? apiUrl, String? accountId}) => {
'apiUrl': apiUrl ?? _apiUrl, 'apiUrl': apiUrl ?? _apiUrl,
'accounts': { 'accounts': {
accountId ?? _accountId: { accountId ?? _accountId: {
'name': 'alice@example.com', 'name': 'alice@example.com',
'isPersonal': true, 'isPersonal': true,
'isReadOnly': false, 'isReadOnly': false,
'accountCapabilities': {}, 'accountCapabilities': {},
}, },
}, },
'primaryAccounts': { 'primaryAccounts': {
'urn:ietf:params:jmap:core': accountId ?? _accountId, 'urn:ietf:params:jmap:core': accountId ?? _accountId,
'urn:ietf:params:jmap:mail': accountId ?? _accountId, 'urn:ietf:params:jmap:mail': accountId ?? _accountId,
}, },
'capabilities': {}, 'capabilities': {},
'username': 'alice@example.com', 'username': 'alice@example.com',
'state': 'st1', 'state': 'st1',
}; };
http.Client _sessionClient({ http.Client _sessionClient({
int sessionStatus = 200, int sessionStatus = 200,
@@ -111,9 +111,7 @@ class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
int unread = 0, int unread = 0,
int total = 0, int total = 0,
}) async { }) async {
await _db await _db.into(_db.mailboxes).insert(
.into(_db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
accountId: _account.id, accountId: _account.id,
+52 -69
View File
@@ -66,16 +66,17 @@ http.Client _mockJmap({required List<Map<String, dynamic>> apiResponses}) {
Map<String, dynamic> _mailboxGetResponse({ Map<String, dynamic> _mailboxGetResponse({
required String state, required String state,
required List<Map<String, dynamic>> list, required List<Map<String, dynamic>> list,
}) => { }) =>
'sessionState': 'sess1', {
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'Mailbox/get', [
{'accountId': 'acct1', 'state': state, 'list': list}, 'Mailbox/get',
'0', {'accountId': 'acct1', 'state': state, 'list': list},
], '0',
], ],
}; ],
};
Map<String, dynamic> _mailboxChangesResponse({ Map<String, dynamic> _mailboxChangesResponse({
required String oldState, required String oldState,
@@ -83,24 +84,25 @@ Map<String, dynamic> _mailboxChangesResponse({
List<String> created = const [], List<String> created = const [],
List<String> updated = const [], List<String> updated = const [],
List<String> destroyed = const [], List<String> destroyed = const [],
}) => { }) =>
'sessionState': 'sess1', {
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'Mailbox/changes', [
{ 'Mailbox/changes',
'accountId': 'acct1', {
'oldState': oldState, 'accountId': 'acct1',
'newState': newState, 'oldState': oldState,
'hasMoreChanges': false, 'newState': newState,
'created': created, 'hasMoreChanges': false,
'updated': updated, 'created': created,
'destroyed': destroyed, 'updated': updated,
}, 'destroyed': destroyed,
'0', },
], '0',
], ],
}; ],
};
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) => Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('IMAP unavailable in unit tests')); Future.error(UnsupportedError('IMAP unavailable in unit tests'));
@@ -109,8 +111,7 @@ Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
AppDatabase db, AppDatabase db,
AccountRepositoryImpl accounts, AccountRepositoryImpl accounts,
MailboxRepositoryImpl mailboxes, MailboxRepositoryImpl mailboxes,
}) }) _makeRepos({http.Client? httpClient}) {
_makeRepos({http.Client? httpClient}) {
final db = openTestDatabase(); final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl( final mailboxes = MailboxRepositoryImpl(
@@ -144,9 +145,7 @@ void main() {
('INBOX', 'Inbox'), ('INBOX', 'Inbox'),
('Drafts', 'Drafts'), ('Drafts', 'Drafts'),
]) { ]) {
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:$path', id: 'acc-1:$path',
accountId: 'acc-1', accountId: 'acc-1',
@@ -179,9 +178,7 @@ void main() {
); );
await r.accounts.addAccount(other, 'pw2'); await r.accounts.addAccount(other, 'pw2');
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:INBOX', id: 'acc-1:INBOX',
accountId: 'acc-1', accountId: 'acc-1',
@@ -189,9 +186,7 @@ void main() {
name: 'Inbox', name: 'Inbox',
), ),
); );
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-2:INBOX', id: 'acc-2:INBOX',
accountId: 'acc-2', accountId: 'acc-2',
@@ -210,9 +205,7 @@ void main() {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:INBOX', id: 'acc-1:INBOX',
accountId: 'acc-1', accountId: 'acc-1',
@@ -312,9 +305,7 @@ void main() {
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
// Pre-populate DB with existing mailboxes and state // Pre-populate DB with existing mailboxes and state
await r.db await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
.into(r.db.mailboxes)
.insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'jmap-1:mbx1', id: 'jmap-1:mbx1',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -324,9 +315,7 @@ void main() {
totalCount: const Value(10), totalCount: const Value(10),
), ),
); );
await r.db await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
.into(r.db.mailboxes)
.insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'jmap-1:mbx2', id: 'jmap-1:mbx2',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -334,9 +323,7 @@ void main() {
name: 'Sent', name: 'Sent',
), ),
); );
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Mailbox', resourceType: 'Mailbox',
@@ -364,9 +351,7 @@ void main() {
), ),
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.syncStates).insertOnConflictUpdate(
.into(r.db.syncStates)
.insertOnConflictUpdate(
SyncStatesCompanion.insert( SyncStatesCompanion.insert(
accountId: 'jmap-1', accountId: 'jmap-1',
resourceType: 'Mailbox', resourceType: 'Mailbox',
@@ -434,9 +419,7 @@ void main() {
test('findMailboxByRole returns matching mailbox', () async { test('findMailboxByRole returns matching mailbox', () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db await r.db.into(r.db.mailboxes).insert(
.into(r.db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'jmap-1:mbx-inbox', id: 'jmap-1:mbx-inbox',
accountId: 'jmap-1', accountId: 'jmap-1',
@@ -569,9 +552,7 @@ void main() {
await accounts.addAccount(_account, 'pw'); await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder). // Pre-seed the DB with role='archive' (as if user created the folder).
await db await db.into(db.mailboxes).insert(
.into(db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:Archive', id: 'acc-1:Archive',
accountId: 'acc-1', accountId: 'acc-1',
@@ -608,20 +589,22 @@ class _PlainArchiveImapClient extends SnoozeSpyImapClient {
List<String>? mailboxPatterns, List<String>? mailboxPatterns,
List<String>? selectionOptions, List<String>? selectionOptions,
List<imap.ReturnOption>? returnOptions, List<imap.ReturnOption>? returnOptions,
}) async => [ }) async =>
imap.Mailbox( [
encodedName: 'Archive', imap.Mailbox(
encodedPath: 'Archive', encodedName: 'Archive',
pathSeparator: '/', encodedPath: 'Archive',
flags: [], // No \Archive special-use flag pathSeparator: '/',
), flags: [], // No \Archive special-use flag
]; ),
];
@override @override
Future<imap.Mailbox> statusMailbox( Future<imap.Mailbox> statusMailbox(
imap.Mailbox mailbox, imap.Mailbox mailbox,
List<imap.StatusFlags> flags, List<imap.StatusFlags> flags,
) async => mailbox; ) async =>
mailbox;
@override @override
Future<dynamic> logout() async {} Future<dynamic> logout() async {}
+40 -44
View File
@@ -27,12 +27,12 @@ class _RecordingRepo implements AccountRepository {
ManageSieveProbeService _service(_RecordingRepo repo, {required bool result}) { ManageSieveProbeService _service(_RecordingRepo repo, {required bool result}) {
return ManageSieveProbeService( return ManageSieveProbeService(
repo, repo,
probeFn: probeFn: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async =>
}) async => result, result,
); );
} }
@@ -71,15 +71,14 @@ void main() {
var probeCalled = false; var probeCalled = false;
final svc = ManageSieveProbeService( final svc = ManageSieveProbeService(
repo, repo,
probeFn: probeFn: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async {
}) async { probeCalled = true;
probeCalled = true; return true;
return true; },
},
); );
const jmap = Account( const jmap = Account(
id: 'acc-2', id: 'acc-2',
@@ -98,15 +97,14 @@ void main() {
var probeCalled = false; var probeCalled = false;
final svc = ManageSieveProbeService( final svc = ManageSieveProbeService(
repo, repo,
probeFn: probeFn: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async {
}) async { probeCalled = true;
probeCalled = true; return true;
return true; },
},
); );
const blank = Account( const blank = Account(
id: 'acc-3', id: 'acc-3',
@@ -125,17 +123,16 @@ void main() {
bool? probedTls; bool? probedTls;
final svc = ManageSieveProbeService( final svc = ManageSieveProbeService(
repo, repo,
probeFn: probeFn: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async {
}) async { probedHost = host;
probedHost = host; probedPort = port;
probedPort = port; probedTls = useTls;
probedTls = useTls; return true;
return true; },
},
); );
const account = Account( const account = Account(
id: 'acc-1', id: 'acc-1',
@@ -158,15 +155,14 @@ void main() {
String? probedHost; String? probedHost;
final svc = ManageSieveProbeService( final svc = ManageSieveProbeService(
repo, repo,
probeFn: probeFn: ({
({ required String host,
required String host, required int port,
required int port, required bool useTls,
required bool useTls, }) async {
}) async { probedHost = host;
probedHost = host; return true;
return true; },
},
); );
await svc.probe(_imapAccount); await svc.probe(_imapAccount);
expect(probedHost, 'imap.example.com'); expect(probedHost, 'imap.example.com');
+6 -9
View File
@@ -162,9 +162,8 @@ void main() {
final allTriggers = await db final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get(); .get();
final triggerNames = allTriggers final triggerNames =
.map((r) => r.read<String>('name')) allTriggers.map((r) => r.read<String>('name')).toSet();
.toSet();
expect( expect(
triggerNames, triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
@@ -361,9 +360,8 @@ void main() {
final allIndexes = await db final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'") .customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get(); .get();
final indexNames = allIndexes final indexNames =
.map((r) => r.read<String>('name')) allIndexes.map((r) => r.read<String>('name')).toSet();
.toSet();
expect(indexNames, contains('mailboxes_account_id')); expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('threads_latest_date')); expect(indexNames, contains('threads_latest_date'));
@@ -371,9 +369,8 @@ void main() {
final allTriggers = await db final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get(); .get();
final triggerNames = allTriggers final triggerNames =
.map((r) => r.read<String>('name')) allTriggers.map((r) => r.read<String>('name')).toSet();
.toSet();
expect( expect(
triggerNames, triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
@@ -67,15 +67,16 @@ class _FakeMailboxes implements MailboxRepository {
String accountId, String accountId,
String name, String name,
String role, String role,
) async => Mailbox( ) async =>
id: '$accountId:$name', Mailbox(
accountId: accountId, id: '$accountId:$name',
path: name, accountId: accountId,
name: name, path: name,
role: role, name: name,
unreadCount: 0, role: role,
totalCount: 0, unreadCount: 0,
); totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
@@ -99,7 +100,8 @@ class _FakeEmails implements EmailRepository {
String a, String a,
String m, { String m, {
int limit = 50, int limit = 50,
}) => Stream.value([]); }) =>
Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
@@ -136,7 +138,8 @@ class _FakeEmails implements EmailRepository {
String? a, String? a,
String q, { String q, {
int limit = 10, int limit = 10,
}) async => []; }) async =>
[];
@override @override
Stream<List<FailedMutation>> observeFailedMutations(String a) => Stream<List<FailedMutation>> observeFailedMutations(String a) =>
Stream.value([]); Stream.value([]);
+24 -19
View File
@@ -13,11 +13,11 @@ import 'package:sharedinbox/core/sync/account_sync_manager.dart';
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
Account _account({String id = 'a1'}) => Account( Account _account({String id = 'a1'}) => Account(
id: id, id: id,
displayName: 'Test', displayName: 'Test',
email: 'test@example.com', email: 'test@example.com',
imapHost: 'localhost', imapHost: 'localhost',
); );
class _FakeAccounts implements AccountRepository { class _FakeAccounts implements AccountRepository {
final List<Account> accounts; final List<Account> accounts;
@@ -57,15 +57,16 @@ class _FakeMailboxes implements MailboxRepository {
String accountId, String accountId,
String name, String name,
String role, String role,
) async => Mailbox( ) async =>
id: '$accountId:$name', Mailbox(
accountId: accountId, id: '$accountId:$name',
path: name, accountId: accountId,
name: name, path: name,
role: role, name: name,
unreadCount: 0, role: role,
totalCount: 0, unreadCount: 0,
); totalCount: 0,
);
} }
class _CountingEmails implements EmailRepository { class _CountingEmails implements EmailRepository {
@@ -98,7 +99,8 @@ class _CountingEmails implements EmailRepository {
String a, String a,
String m, { String m, {
int limit = 50, int limit = 50,
}) => Stream.value([]); }) =>
Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
@@ -132,7 +134,8 @@ class _CountingEmails implements EmailRepository {
String? a, String? a,
String q, { String q, {
int limit = 10, int limit = 10,
}) async => []; }) async =>
[];
@override @override
Stream<List<FailedMutation>> observeFailedMutations(String a) => Stream<List<FailedMutation>> observeFailedMutations(String a) =>
Stream.value([]); Stream.value([]);
@@ -150,7 +153,8 @@ class _CountingEmails implements EmailRepository {
Future<Email?> findEmailByMessageId( Future<Email?> findEmailByMessageId(
String accountId, String accountId,
String messageId, String messageId,
) async => null; ) async =>
null;
@override @override
Stream<String> get onChangesQueued => const Stream.empty(); Stream<String> get onChangesQueued => const Stream.empty();
@override @override
@@ -160,7 +164,8 @@ class _CountingEmails implements EmailRepository {
Future<ReliabilityResult> verifySyncReliability( Future<ReliabilityResult> verifySyncReliability(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
) async => ReliabilityResult.healthy; ) async =>
ReliabilityResult.healthy;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override @override
@@ -372,7 +377,7 @@ void main() {
class _OverrideEmails extends _CountingEmails { class _OverrideEmails extends _CountingEmails {
_OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync}) _OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync})
: _onSync = onSync; : _onSync = onSync;
final Future<SyncEmailsResult> Function(String) _onSync; final Future<SyncEmailsResult> Function(String) _onSync;
+3 -4
View File
@@ -11,9 +11,7 @@ void main() {
late final db = openTestDatabase(); late final db = openTestDatabase();
setUpAll(() async { setUpAll(() async {
await db await db.into(db.accounts).insert(
.into(db.accounts)
.insert(
AccountsCompanion.insert( AccountsCompanion.insert(
id: 'acc1', id: 'acc1',
displayName: 'Test', displayName: 'Test',
@@ -122,7 +120,8 @@ void main() {
final rows = await (db.select( final rows = await (db.select(
db.syncLogs, db.syncLogs,
)..where((r) => r.result.equals('error'))).get(); )..where((r) => r.result.equals('error')))
.get();
expect(rows, hasLength(1)); expect(rows, hasLength(1));
expect(rows.first.result, 'error'); expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
+29 -47
View File
@@ -48,9 +48,7 @@ void main() {
await accounts.addAccount(account, 'password'); await accounts.addAccount(account, 'password');
// Setup Inbox and Trash mailboxes // Setup Inbox and Trash mailboxes
await db await db.into(db.mailboxes).insert(
.into(db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc1:INBOX', id: 'acc1:INBOX',
accountId: 'acc1', accountId: 'acc1',
@@ -58,9 +56,7 @@ void main() {
name: 'Inbox', name: 'Inbox',
), ),
); );
await db await db.into(db.mailboxes).insert(
.into(db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc1:Trash', id: 'acc1:Trash',
accountId: 'acc1', accountId: 'acc1',
@@ -71,9 +67,7 @@ void main() {
); );
// Setup an email in Inbox // Setup an email in Inbox
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc1:101', id: 'acc1:101',
accountId: 'acc1', accountId: 'acc1',
@@ -100,11 +94,10 @@ void main() {
await repo.deleteEmail(emailId); await repo.deleteEmail(emailId);
// Verify it moved from INBOX (locally deleted for IMAP move) // Verify it moved from INBOX (locally deleted for IMAP move)
final inInbox = final inInbox = await (db.select(db.emails)
await (db.select(db.emails) ..where((t) => t.id.equals(emailId))
..where((t) => t.id.equals(emailId)) ..where((t) => t.mailboxPath.equals('INBOX')))
..where((t) => t.mailboxPath.equals('INBOX'))) .get();
.get();
expect(inInbox, isEmpty, reason: 'Email should be gone from Inbox'); expect(inInbox, isEmpty, reason: 'Email should be gone from Inbox');
// 2. Push undo action and undo // 2. Push undo action and undo
@@ -120,11 +113,10 @@ void main() {
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox // 3. Verify it is back in Inbox
final restored = final restored = await (db.select(db.emails)
await (db.select(db.emails) ..where((t) => t.id.equals(emailId))
..where((t) => t.id.equals(emailId)) ..where((t) => t.mailboxPath.equals('INBOX')))
..where((t) => t.mailboxPath.equals('INBOX'))) .get();
.get();
expect( expect(
restored, restored,
@@ -149,9 +141,7 @@ void main() {
await accounts.addAccount(jmapAccount, 'password'); await accounts.addAccount(jmapAccount, 'password');
// Setup Inbox and Trash mailboxes for JMAP // Setup Inbox and Trash mailboxes for JMAP
await db await db.into(db.mailboxes).insert(
.into(db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'jmap1:INBOX', id: 'jmap1:INBOX',
accountId: 'jmap1', accountId: 'jmap1',
@@ -160,9 +150,7 @@ void main() {
role: const Value('inbox'), role: const Value('inbox'),
), ),
); );
await db await db.into(db.mailboxes).insert(
.into(db.mailboxes)
.insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'jmap1:Trash', id: 'jmap1:Trash',
accountId: 'jmap1', accountId: 'jmap1',
@@ -173,9 +161,7 @@ void main() {
); );
// Setup an email in JMAP Inbox // Setup an email in JMAP Inbox
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: emailId, id: emailId,
accountId: 'jmap1', accountId: 'jmap1',
@@ -190,11 +176,10 @@ void main() {
await repo.deleteEmail(emailId); await repo.deleteEmail(emailId);
// Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath) // Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath)
final inTrash = final inTrash = await (db.select(db.emails)
await (db.select(db.emails) ..where((t) => t.id.equals(emailId))
..where((t) => t.id.equals(emailId)) ..where((t) => t.mailboxPath.equals('Trash')))
..where((t) => t.mailboxPath.equals('Trash'))) .get();
.get();
expect(inTrash, isNotEmpty, reason: 'Email should be in Trash'); expect(inTrash, isNotEmpty, reason: 'Email should be in Trash');
// 2. Push undo action and undo // 2. Push undo action and undo
@@ -209,11 +194,10 @@ void main() {
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox // 3. Verify it is back in Inbox
final restored = final restored = await (db.select(db.emails)
await (db.select(db.emails) ..where((t) => t.id.equals(emailId))
..where((t) => t.id.equals(emailId)) ..where((t) => t.mailboxPath.equals('INBOX')))
..where((t) => t.mailboxPath.equals('INBOX'))) .get();
.get();
expect( expect(
restored, restored,
isNotEmpty, isNotEmpty,
@@ -250,11 +234,10 @@ void main() {
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 4. Verify local state // 4. Verify local state
final restored = final restored = await (db.select(db.emails)
await (db.select(db.emails) ..where((t) => t.id.equals(emailId))
..where((t) => t.id.equals(emailId)) ..where((t) => t.mailboxPath.equals('INBOX')))
..where((t) => t.mailboxPath.equals('INBOX'))) .get();
.get();
expect(restored, isNotEmpty); expect(restored, isNotEmpty);
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX) // 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
@@ -290,9 +273,7 @@ void main() {
// 2. Simulate IMAP sync: the server assigned a new UID (205) in Trash. // 2. Simulate IMAP sync: the server assigned a new UID (205) in Trash.
// The old row (acc1:101) is removed and a new row (acc1:205) is inserted. // The old row (acc1:101) is removed and a new row (acc1:205) is inserted.
await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go(); await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go();
await db await db.into(db.emails).insert(
.into(db.emails)
.insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc1:205', id: 'acc1:205',
accountId: 'acc1', accountId: 'acc1',
@@ -325,7 +306,8 @@ void main() {
// 4. Verify the current email row is now in INBOX. // 4. Verify the current email row is now in INBOX.
final inInbox = await (db.select( final inInbox = await (db.select(
db.emails, db.emails,
)..where((t) => t.mailboxPath.equals('INBOX'))).get(); )..where((t) => t.mailboxPath.equals('INBOX')))
.get();
expect( expect(
inInbox, inInbox,
isNotEmpty, isNotEmpty,
+1 -2
View File
@@ -37,8 +37,7 @@ class ThrowingUrlLauncher extends Mock
Future<bool> launchUrl(String? url, LaunchOptions? options) async { Future<bool> launchUrl(String? url, LaunchOptions? options) async {
throw PlatformException( throw PlatformException(
code: 'channel-error', code: 'channel-error',
message: message: 'Unable to establish connection on channel: '
'Unable to establish connection on channel: '
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".', '"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
); );
} }
+2 -1
View File
@@ -227,7 +227,8 @@ void main() {
expect(find.textContaining('Healthy'), findsOneWidget); expect(find.textContaining('Healthy'), findsOneWidget);
}); });
testWidgets('shows discrepancy details when sync health has discrepancies', ( testWidgets('shows discrepancy details when sync health has discrepancies',
(
tester, tester,
) async { ) async {
const summary = const summary =
+10 -9
View File
@@ -41,19 +41,20 @@ class _FakeFile extends Fake implements File {
FileMode mode = FileMode.write, FileMode mode = FileMode.write,
Encoding encoding = utf8, Encoding encoding = utf8,
bool flush = false, bool flush = false,
}) async => this; }) async =>
this;
} }
// Shared overrides for email detail tests. // Shared overrides for email detail tests.
List<Override> _overrides({required EmailBody body, Email? email}) => [ List<Override> _overrides({required EmailBody body, Email? email}) => [
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body), FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
), ),
]; ];
void main() { void main() {
group('EmailDetailScreen', () { group('EmailDetailScreen', () {
+33 -31
View File
@@ -15,42 +15,44 @@ Email _email({
String subject = 'Hello world', String subject = 'Hello world',
bool isSeen = true, bool isSeen = true,
bool isFlagged = false, bool isFlagged = false,
}) => Email( }) =>
id: id, Email(
accountId: 'acc-1', id: id,
mailboxPath: 'INBOX', accountId: 'acc-1',
uid: int.parse(id.split(':').last), mailboxPath: 'INBOX',
subject: subject, uid: int.parse(id.split(':').last),
receivedAt: _kDate, subject: subject,
sentAt: _kDate, receivedAt: _kDate,
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], sentAt: _kDate,
to: const [EmailAddress(email: 'alice@example.com')], from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
cc: const [], to: const [EmailAddress(email: 'alice@example.com')],
isSeen: isSeen, cc: const [],
isFlagged: isFlagged, isSeen: isSeen,
hasAttachment: false, isFlagged: isFlagged,
); hasAttachment: false,
);
List<Override> _overrides({ List<Override> _overrides({
List<Email> emails = const [], List<Email> emails = const [],
List<Email> searchResults = const [], List<Email> searchResults = const [],
String? syncError, String? syncError,
}) => [ }) =>
accountRepositoryProvider.overrideWithValue( [
FakeAccountRepository([kTestAccount]), accountRepositoryProvider.overrideWithValue(
), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider.overrideWithValue( ),
FakeMailboxRepository([kTestMailbox]), mailboxRepositoryProvider.overrideWithValue(
), FakeMailboxRepository([kTestMailbox]),
emailRepositoryProvider.overrideWithValue( ),
FakeEmailRepository(emails: emails, searchResults: searchResults), emailRepositoryProvider.overrideWithValue(
), FakeEmailRepository(emails: emails, searchResults: searchResults),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), ),
searchHistoryRepositoryProvider.overrideWithValue( draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
FakeSearchHistoryRepository(), searchHistoryRepositoryProvider.overrideWithValue(
), FakeSearchHistoryRepository(),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)), ),
]; syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
];
void main() { void main() {
group('EmailListScreen goldens', () { group('EmailListScreen goldens', () {
+2 -1
View File
@@ -27,7 +27,8 @@ class _MutableFakeEmailRepository extends FakeEmailRepository {
String accountId, String accountId,
String mailboxPath, String mailboxPath,
String query, String query,
) async => _results; ) async =>
_results;
} }
final _kDate = DateTime(2024, 6); final _kDate = DateTime(2024, 6);
+93 -78
View File
@@ -49,7 +49,7 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
class FakeAccountRepository implements AccountRepository { class FakeAccountRepository implements AccountRepository {
FakeAccountRepository([List<Account>? accounts]) FakeAccountRepository([List<Account>? accounts])
: _accounts = List.of(accounts ?? []); : _accounts = List.of(accounts ?? []);
final List<Account> _accounts; final List<Account> _accounts;
bool hasPassword = true; bool hasPassword = true;
@@ -137,7 +137,8 @@ class FakeDraftRepository implements DraftRepository {
final matches = _drafts.values.where((d) { final matches = _drafts.values.where((d) {
if (replyToEmailId == null) return d.replyToEmailId == null; if (replyToEmailId == null) return d.replyToEmailId == null;
return d.replyToEmailId == replyToEmailId; return d.replyToEmailId == replyToEmailId;
}).toList()..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); }).toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return matches.isEmpty ? null : matches.first; return matches.isEmpty ? null : matches.first;
} }
@@ -155,7 +156,7 @@ class FakeMailboxRepository implements MailboxRepository {
final List<Mailbox> _mailboxes; final List<Mailbox> _mailboxes;
FakeMailboxRepository([List<Mailbox>? mailboxes]) FakeMailboxRepository([List<Mailbox>? mailboxes])
: _mailboxes = mailboxes ?? []; : _mailboxes = mailboxes ?? [];
@override @override
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
@@ -205,49 +206,52 @@ class FakeEmailRepository implements EmailRepository {
EmailBody? emailBody, EmailBody? emailBody,
List<Email>? searchResults, List<Email>? searchResults,
String rawRfc822 = '', String rawRfc822 = '',
}) : _emails = emails ?? [], }) : _emails = emails ?? [],
_emailDetail = emailDetail, _emailDetail = emailDetail,
_searchResults = searchResults ?? [], _searchResults = searchResults ?? [],
_rawRfc822 = rawRfc822, _rawRfc822 = rawRfc822,
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []); _emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(
String accountId, String accountId,
String mailboxPath, { String mailboxPath, {
int limit = 50, int limit = 50,
}) => Stream.value(List.of(_emails)); }) =>
Stream.value(List.of(_emails));
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
String accountId, String accountId,
String mailboxPath, { String mailboxPath, {
int limit = 50, int limit = 50,
}) => observeEmails(accountId, mailboxPath).map((emails) { }) =>
return emails.map((e) { observeEmails(accountId, mailboxPath).map((emails) {
return EmailThread( return emails.map((e) {
threadId: e.threadId ?? e.id, return EmailThread(
subject: e.subject, threadId: e.threadId ?? e.id,
preview: e.preview, subject: e.subject,
participants: e.from, preview: e.preview,
latestDate: e.sentAt ?? e.receivedAt, participants: e.from,
messageCount: 1, latestDate: e.sentAt ?? e.receivedAt,
hasUnread: !e.isSeen, messageCount: 1,
isFlagged: e.isFlagged, hasUnread: !e.isSeen,
latestEmailId: e.id, isFlagged: e.isFlagged,
emailIds: [e.id], latestEmailId: e.id,
accountId: e.accountId, emailIds: [e.id],
mailboxPath: e.mailboxPath, accountId: e.accountId,
); mailboxPath: e.mailboxPath,
}).toList(); );
}); }).toList();
});
@override @override
Stream<List<Email>> observeEmailsInThread( Stream<List<Email>> observeEmailsInThread(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
String threadId, String threadId,
) => Stream.value(_emails.where((e) => e.threadId == threadId).toList()); ) =>
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
@override @override
Future<Email?> getEmail(String emailId) async => _emailDetail; Future<Email?> getEmail(String emailId) async => _emailDetail;
@@ -259,7 +263,8 @@ class FakeEmailRepository implements EmailRepository {
Future<SyncEmailsResult> syncEmails( Future<SyncEmailsResult> syncEmails(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
) async => SyncEmailsResult.zero; ) async =>
SyncEmailsResult.zero;
@override @override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@@ -285,7 +290,8 @@ class FakeEmailRepository implements EmailRepository {
Future<Email?> findEmailByMessageId( Future<Email?> findEmailByMessageId(
String accountId, String accountId,
String messageId, String messageId,
) async => null; ) async =>
null;
@override @override
Future<String?> deleteEmail(String emailId) async => null; Future<String?> deleteEmail(String emailId) async => null;
@@ -303,7 +309,8 @@ class FakeEmailRepository implements EmailRepository {
Future<String> downloadAttachment( Future<String> downloadAttachment(
String emailId, String emailId,
EmailAttachment attachment, EmailAttachment attachment,
) async => '/tmp/${attachment.filename}'; ) async =>
'/tmp/${attachment.filename}';
@override @override
Future<String> fetchRawRfc822(String emailId) async => _rawRfc822; Future<String> fetchRawRfc822(String emailId) async => _rawRfc822;
@@ -313,26 +320,30 @@ class FakeEmailRepository implements EmailRepository {
String accountId, String accountId,
String mailboxPath, String mailboxPath,
String query, String query,
) async => _searchResults; ) async =>
_searchResults;
@override @override
Future<List<Email>> searchEmailsGlobal( Future<List<Email>> searchEmailsGlobal(
String? accountId, String? accountId,
String query, String query,
) async => _searchResults; ) async =>
_searchResults;
@override @override
Future<List<Email>> getEmailsByAddress( Future<List<Email>> getEmailsByAddress(
String? accountId, String? accountId,
String address, String address,
) async => []; ) async =>
[];
@override @override
Future<List<EmailAddress>> searchAddresses( Future<List<EmailAddress>> searchAddresses(
String? accountId, String? accountId,
String query, { String query, {
int limit = 10, int limit = 10,
}) async => []; }) async =>
[];
@override @override
Stream<void> watchJmapPush(String accountId, String password) => Stream<void> watchJmapPush(String accountId, String password) =>
@@ -342,7 +353,8 @@ class FakeEmailRepository implements EmailRepository {
Future<ReliabilityResult> verifySyncReliability( Future<ReliabilityResult> verifySyncReliability(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
) async => ReliabilityResult.healthy; ) async =>
ReliabilityResult.healthy;
@override @override
Stream<List<FailedMutation>> observeFailedMutations(String accountId) => Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
@@ -541,26 +553,28 @@ List<Override> baseOverrides({
ShareKeyRepository? shareKeyRepository, ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true, bool hasStoredPassword = true,
SyncHealthRow? syncHealth, SyncHealthRow? syncHealth,
}) => [ }) =>
accountRepositoryProvider.overrideWithValue( [
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword, accountRepositoryProvider.overrideWithValue(
), FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)), ),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), mailboxRepositoryProvider
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), .overrideWithValue(FakeMailboxRepository(mailboxes)),
accountDiscoveryServiceProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
FakeDiscoveryService(discovery ?? UnknownDiscovery()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
), accountDiscoveryServiceProvider.overrideWithValue(
connectionTestServiceProvider.overrideWithValue( FakeDiscoveryService(discovery ?? UnknownDiscovery()),
FakeConnectionTestService(error: connectionError), ),
), connectionTestServiceProvider.overrideWithValue(
shareKeyRepositoryProvider.overrideWithValue( FakeConnectionTestService(error: connectionError),
shareKeyRepository ?? FakeShareKeyRepository(), ),
), shareKeyRepositoryProvider.overrideWithValue(
// syncHealthProvider is backed by a Drift StreamQuery; override with a shareKeyRepository ?? FakeShareKeyRepository(),
// plain stream to avoid "A Timer is still pending" in tests. ),
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)), // syncHealthProvider is backed by a Drift StreamQuery; override with a
]; // plain stream to avoid "A Timer is still pending" in tests.
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Common test fixtures // Common test fixtures
@@ -590,22 +604,23 @@ Email testEmail({
bool isFlagged = false, bool isFlagged = false,
bool hasAttachment = false, bool hasAttachment = false,
String? listUnsubscribeHeader, String? listUnsubscribeHeader,
}) => Email( }) =>
id: id, Email(
accountId: 'acc-1', id: id,
mailboxPath: 'INBOX', accountId: 'acc-1',
uid: 42, mailboxPath: 'INBOX',
subject: subject, uid: 42,
receivedAt: DateTime(2024, 6), subject: subject,
sentAt: DateTime(2024, 6), receivedAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], sentAt: DateTime(2024, 6),
to: const [EmailAddress(email: 'alice@example.com')], from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
cc: const [], to: const [EmailAddress(email: 'alice@example.com')],
isSeen: isSeen, cc: const [],
isFlagged: isFlagged, isSeen: isSeen,
hasAttachment: hasAttachment, isFlagged: isFlagged,
listUnsubscribeHeader: listUnsubscribeHeader, hasAttachment: hasAttachment,
); listUnsubscribeHeader: listUnsubscribeHeader,
);
class FakeUserPreferencesRepository implements UserPreferencesRepository { class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({ FakeUserPreferencesRepository({
@@ -620,12 +635,12 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
@override @override
Stream<UserPreferences> observePreferences() => Stream.value( Stream<UserPreferences> observePreferences() => Stream.value(
UserPreferences( UserPreferences(
menuPosition: menuPosition, menuPosition: menuPosition,
mailViewButtonPosition: mailViewButtonPosition, mailViewButtonPosition: mailViewButtonPosition,
afterMailViewAction: afterMailViewAction, afterMailViewAction: afterMailViewAction,
), ),
); );
@override @override
Future<void> updateMenuPosition(MenuPosition position) async { Future<void> updateMenuPosition(MenuPosition position) async {
+8 -7
View File
@@ -11,12 +11,12 @@ void _expectLightMode(String html) {
} }
Widget _wrap(Widget child) => MaterialApp( Widget _wrap(Widget child) => MaterialApp(
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true, useMaterial3: true,
), ),
home: Scaffold(body: child), home: Scaffold(body: child),
); );
void main() { void main() {
group('buildEmailHtml', () { group('buildEmailHtml', () {
@@ -44,7 +44,8 @@ void main() {
_expectLightMode(html); _expectLightMode(html);
}); });
test('prevents horizontal overflow so wide HTML emails are not cut off', () { test('prevents horizontal overflow so wide HTML emails are not cut off',
() {
final html = buildEmailHtml( final html = buildEmailHtml(
'<table width="600"><tr><td>x</td></tr></table>', '<table width="600"><tr><td>x</td></tr></table>',
); );
+17 -16
View File
@@ -11,22 +11,23 @@ Email _threadEmail({
String id = 'acc-1:10', String id = 'acc-1:10',
bool isFlagged = false, bool isFlagged = false,
bool isSeen = true, bool isSeen = true,
}) => Email( }) =>
id: id, Email(
accountId: 'acc-1', id: id,
mailboxPath: 'INBOX', accountId: 'acc-1',
uid: 10, mailboxPath: 'INBOX',
threadId: 'thread-1', uid: 10,
subject: 'Project update', threadId: 'thread-1',
receivedAt: DateTime(2024, 6), subject: 'Project update',
sentAt: DateTime(2024, 6, 1, 9), receivedAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], sentAt: DateTime(2024, 6, 1, 9),
to: const [EmailAddress(email: 'alice@example.com')], from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
cc: const [], to: const [EmailAddress(email: 'alice@example.com')],
isSeen: isSeen, cc: const [],
isFlagged: isFlagged, isSeen: isSeen,
hasAttachment: false, isFlagged: isFlagged,
); hasAttachment: false,
);
void main() { void main() {
group('ThreadDetailScreen', () { group('ThreadDetailScreen', () {
+6 -6
View File
@@ -4,12 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/widgets/try_connection_button.dart'; import 'package:sharedinbox/ui/widgets/try_connection_button.dart';
Widget _wrap(Widget child) => MaterialApp( Widget _wrap(Widget child) => MaterialApp(
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true, useMaterial3: true,
), ),
home: Scaffold(body: child), home: Scaffold(body: child),
); );
void main() { void main() {
group('TryConnectionButton', () { group('TryConnectionButton', () {
+12 -15
View File
@@ -88,11 +88,10 @@ void main() {
await tester.tap(find.text('Top').first); await tester.tap(find.text('Top').first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = final repo = ProviderScope.containerOf(
ProviderScope.containerOf( tester.element(find.byType(UserPreferencesScreen)),
tester.element(find.byType(UserPreferencesScreen)), ).read(userPreferencesRepositoryProvider)
).read(userPreferencesRepositoryProvider) as FakeUserPreferencesRepository;
as FakeUserPreferencesRepository;
expect(repo.menuPosition, MenuPosition.top); expect(repo.menuPosition, MenuPosition.top);
}); });
@@ -111,11 +110,10 @@ void main() {
await tester.tap(find.text('Top').last); await tester.tap(find.text('Top').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = final repo = ProviderScope.containerOf(
ProviderScope.containerOf( tester.element(find.byType(UserPreferencesScreen)),
tester.element(find.byType(UserPreferencesScreen)), ).read(userPreferencesRepositoryProvider)
).read(userPreferencesRepositoryProvider) as FakeUserPreferencesRepository;
as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top); expect(repo.mailViewButtonPosition, MenuPosition.top);
}, },
@@ -175,11 +173,10 @@ void main() {
await tester.tap(find.text('Return to mailbox')); await tester.tap(find.text('Return to mailbox'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = final repo = ProviderScope.containerOf(
ProviderScope.containerOf( tester.element(find.byType(UserPreferencesScreen)),
tester.element(find.byType(UserPreferencesScreen)), ).read(userPreferencesRepositoryProvider)
).read(userPreferencesRepositoryProvider) as FakeUserPreferencesRepository;
as FakeUserPreferencesRepository;
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox); expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
}); });