chore: migrate to SOPS and SSH for Dagger engine access
This commit is contained in:
@@ -346,10 +346,10 @@ class SyncEmailsResult {
|
||||
);
|
||||
|
||||
SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult(
|
||||
fetched: fetched + other.fetched,
|
||||
skipped: skipped + other.skipped,
|
||||
bytesTransferred: bytesTransferred + other.bytesTransferred,
|
||||
);
|
||||
fetched: fetched + other.fetched,
|
||||
skipped: skipped + other.skipped,
|
||||
bytesTransferred: bytesTransferred + other.bytesTransferred,
|
||||
);
|
||||
}
|
||||
|
||||
class ReliabilityResult {
|
||||
|
||||
@@ -35,8 +35,9 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
|
||||
try {
|
||||
final url = Uri.https(domain, '/.well-known/jmap');
|
||||
final request = http.Request('GET', url)..followRedirects = false;
|
||||
final streamed =
|
||||
await _client.send(request).timeout(const Duration(seconds: 5));
|
||||
final streamed = await _client
|
||||
.send(request)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
String sessionUrl;
|
||||
if (streamed.statusCode >= 300 && streamed.statusCode < 400) {
|
||||
|
||||
@@ -6,30 +6,24 @@ import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
import 'package:sharedinbox/data/imap/managesieve_client.dart';
|
||||
|
||||
typedef ImapConnectForTestFn = Future<imap.ImapClient> Function(
|
||||
Account,
|
||||
String username,
|
||||
String password,
|
||||
);
|
||||
typedef ImapConnectForTestFn =
|
||||
Future<imap.ImapClient> Function(Account, String username, String password);
|
||||
|
||||
typedef SmtpConnectForTestFn = Future<imap.SmtpClient> Function(
|
||||
Account,
|
||||
String username,
|
||||
String password,
|
||||
);
|
||||
typedef SmtpConnectForTestFn =
|
||||
Future<imap.SmtpClient> Function(Account, String username, String password);
|
||||
|
||||
typedef ManageSieveConnectForTestFn = Future<ManageSieveClient> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
});
|
||||
typedef ManageSieveConnectForTestFn =
|
||||
Future<ManageSieveClient> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
});
|
||||
|
||||
Future<ManageSieveClient> _defaultManageSieveConnect({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) =>
|
||||
ManageSieveClient.connect(host: host, port: port, useTls: useTls);
|
||||
}) => ManageSieveClient.connect(host: host, port: port, useTls: useTls);
|
||||
|
||||
abstract class ConnectionTestService {
|
||||
/// Verifies credentials and returns the effective username used.
|
||||
@@ -43,9 +37,9 @@ class ConnectionTestServiceImpl implements ConnectionTestService {
|
||||
ImapConnectForTestFn imapConnect = connectImap,
|
||||
SmtpConnectForTestFn smtpConnect = connectSmtp,
|
||||
ManageSieveConnectForTestFn manageSieveConnect = _defaultManageSieveConnect,
|
||||
}) : _imapConnect = imapConnect,
|
||||
_smtpConnect = smtpConnect,
|
||||
_manageSieveConnect = manageSieveConnect;
|
||||
}) : _imapConnect = imapConnect,
|
||||
_smtpConnect = smtpConnect,
|
||||
_manageSieveConnect = manageSieveConnect;
|
||||
|
||||
final http.Client _httpClient;
|
||||
final ImapConnectForTestFn _imapConnect;
|
||||
@@ -162,12 +156,9 @@ class ConnectionTestServiceImpl implements ConnectionTestService {
|
||||
for (final username in candidates) {
|
||||
try {
|
||||
final credentials = base64.encode(utf8.encode('$username:$password'));
|
||||
final resp = await _httpClient.get(
|
||||
sessionUri,
|
||||
headers: {
|
||||
'Authorization': 'Basic $credentials',
|
||||
},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final resp = await _httpClient
|
||||
.get(sessionUri, headers: {'Authorization': 'Basic $credentials'})
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (resp.statusCode == 401 || resp.statusCode == 403) {
|
||||
lastError = Exception(
|
||||
'Authentication failed: wrong username or password',
|
||||
|
||||
@@ -4,11 +4,12 @@ import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/data/imap/managesieve_client.dart';
|
||||
|
||||
/// Returns true if the endpoint accepts a ManageSieve handshake.
|
||||
typedef ManageSieveProbeFn = Future<bool> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
});
|
||||
typedef ManageSieveProbeFn =
|
||||
Future<bool> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
});
|
||||
|
||||
Future<bool> _defaultManageSieveProbe({
|
||||
required String host,
|
||||
@@ -65,22 +66,22 @@ class ManageSieveProbeService {
|
||||
}
|
||||
|
||||
Account _withAvailability(Account a, bool available) => Account(
|
||||
id: a.id,
|
||||
displayName: a.displayName,
|
||||
email: a.email,
|
||||
username: a.username,
|
||||
type: a.type,
|
||||
imapHost: a.imapHost,
|
||||
imapPort: a.imapPort,
|
||||
imapSsl: a.imapSsl,
|
||||
smtpHost: a.smtpHost,
|
||||
smtpPort: a.smtpPort,
|
||||
smtpSsl: a.smtpSsl,
|
||||
manageSieveHost: a.manageSieveHost,
|
||||
manageSievePort: a.manageSievePort,
|
||||
manageSieveSsl: a.manageSieveSsl,
|
||||
manageSieveAvailable: available,
|
||||
jmapUrl: a.jmapUrl,
|
||||
verbose: a.verbose,
|
||||
);
|
||||
id: a.id,
|
||||
displayName: a.displayName,
|
||||
email: a.email,
|
||||
username: a.username,
|
||||
type: a.type,
|
||||
imapHost: a.imapHost,
|
||||
imapPort: a.imapPort,
|
||||
imapSsl: a.imapSsl,
|
||||
smtpHost: a.smtpHost,
|
||||
smtpPort: a.smtpPort,
|
||||
smtpSsl: a.smtpSsl,
|
||||
manageSieveHost: a.manageSieveHost,
|
||||
manageSievePort: a.manageSievePort,
|
||||
manageSieveSsl: a.manageSieveSsl,
|
||||
manageSieveAvailable: available,
|
||||
jmapUrl: a.jmapUrl,
|
||||
verbose: a.verbose,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ Future<void> initNotifications() async {
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
_initialized = true;
|
||||
} on MissingPluginException {
|
||||
|
||||
@@ -92,8 +92,9 @@ class ShareEncryptionService {
|
||||
) {
|
||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||
try {
|
||||
final data =
|
||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||
final data = Uint8List.fromList(
|
||||
base64.decode(s.substring(_pubKeyPrefix.length)),
|
||||
);
|
||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||
return (
|
||||
keyId: data.sublist(0, _keyIdLen),
|
||||
@@ -165,17 +166,18 @@ class ShareEncryptionService {
|
||||
final cipherBytes = Uint8List.fromList(box.cipherText);
|
||||
final macBytes = Uint8List.fromList(box.mac.bytes);
|
||||
|
||||
final out = Uint8List(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
||||
)
|
||||
..setAll(0, recipientKeyId)
|
||||
..setAll(_keyIdLen, ephPubBytes)
|
||||
..setAll(_keyIdLen + _pubKeyLen, nonce)
|
||||
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
|
||||
..setAll(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
|
||||
macBytes,
|
||||
);
|
||||
final out =
|
||||
Uint8List(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
||||
)
|
||||
..setAll(0, recipientKeyId)
|
||||
..setAll(_keyIdLen, ephPubBytes)
|
||||
..setAll(_keyIdLen + _pubKeyLen, nonce)
|
||||
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
|
||||
..setAll(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
|
||||
macBytes,
|
||||
);
|
||||
|
||||
return '$_encAccountsPrefix${base64.encode(out)}';
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ class UndoService extends Notifier<List<UndoAction>> {
|
||||
|
||||
for (final id in action.emailIds) {
|
||||
// 1. Try to cancel the original change (if not started yet).
|
||||
final cancelled = await repo.cancelPendingChange(id, 'delete') ||
|
||||
final cancelled =
|
||||
await repo.cancelPendingChange(id, 'delete') ||
|
||||
await repo.cancelPendingChange(id, 'move') ||
|
||||
await repo.cancelPendingChange(id, 'snooze');
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||
final platformKey = Platform.isLinux
|
||||
? 'linux'
|
||||
: Platform.isWindows
|
||||
? 'windows'
|
||||
: null;
|
||||
? 'windows'
|
||||
: null;
|
||||
if (platformKey == null || _kAppVersion.isEmpty) return null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -64,8 +64,9 @@ class SieveInterpreter {
|
||||
return switch (rule.joinType) {
|
||||
'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
|
||||
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
|
||||
_ => rule.conditions.length == 1 &&
|
||||
_evalCondition(rule.conditions.first, email),
|
||||
_ =>
|
||||
rule.conditions.length == 1 &&
|
||||
_evalCondition(rule.conditions.first, email),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,8 +109,9 @@ class SieveInterpreter {
|
||||
}
|
||||
|
||||
bool _globMatch(String value, String pattern) {
|
||||
final regexStr =
|
||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
final regexStr = RegExp.escape(
|
||||
pattern,
|
||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$').hasMatch(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -421,8 +421,8 @@ class _Scanner {
|
||||
if (_isWordChar(ch)) {
|
||||
final start = _pos;
|
||||
var end = _pos + 1;
|
||||
while (
|
||||
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
|
||||
while (end < _src.length &&
|
||||
(_isWordChar(_src[end]) || _src[end] == ':')) {
|
||||
// Include trailing colon for "text:" multiline token.
|
||||
if (_src[end] == ':') {
|
||||
end++;
|
||||
@@ -466,9 +466,7 @@ class _Scanner {
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw SieveParseException(
|
||||
'Expected tagged argument at position $_pos',
|
||||
);
|
||||
throw SieveParseException('Expected tagged argument at position $_pos');
|
||||
}
|
||||
|
||||
String? peekSizeUnit() {
|
||||
@@ -480,9 +478,7 @@ class _Scanner {
|
||||
|
||||
String readDigits() {
|
||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||
throw SieveParseException(
|
||||
'Expected number at position $_pos',
|
||||
);
|
||||
throw SieveParseException('Expected number at position $_pos');
|
||||
}
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||
@@ -493,9 +489,7 @@ class _Scanner {
|
||||
|
||||
String readQuotedString() {
|
||||
if (_src[_pos] != '"') {
|
||||
throw SieveParseException(
|
||||
'Expected " at position $_pos',
|
||||
);
|
||||
throw SieveParseException('Expected " at position $_pos');
|
||||
}
|
||||
_pos++; // skip opening quote
|
||||
final buf = StringBuffer();
|
||||
|
||||
@@ -29,10 +29,10 @@ class AccountSyncManager {
|
||||
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
|
||||
DraftRepository? drafts,
|
||||
OnNewMailCallback? onNewMail,
|
||||
}) : _imapConnect = imapConnect,
|
||||
_syncLog = syncLog,
|
||||
_drafts = drafts,
|
||||
_onNewMail = onNewMail;
|
||||
}) : _imapConnect = imapConnect,
|
||||
_syncLog = syncLog,
|
||||
_drafts = drafts,
|
||||
_onNewMail = onNewMail;
|
||||
|
||||
final AccountRepository _accounts;
|
||||
final MailboxRepository _mailboxes;
|
||||
@@ -69,26 +69,26 @@ class AccountSyncManager {
|
||||
final id = account.id;
|
||||
final loop = switch (account.type) {
|
||||
AccountType.imap => _AccountSync(
|
||||
account,
|
||||
_accounts,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_imapConnect,
|
||||
_syncLog,
|
||||
_drafts,
|
||||
_onNewMail,
|
||||
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||
),
|
||||
account,
|
||||
_accounts,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_imapConnect,
|
||||
_syncLog,
|
||||
_drafts,
|
||||
_onNewMail,
|
||||
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||
),
|
||||
AccountType.jmap => _JmapAccountSync(
|
||||
account,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_accounts,
|
||||
_syncLog,
|
||||
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||
),
|
||||
account,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_accounts,
|
||||
_syncLog,
|
||||
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||
),
|
||||
};
|
||||
_active[account.id] = loop;
|
||||
loop.start();
|
||||
@@ -129,33 +129,33 @@ class AccountSyncManager {
|
||||
|
||||
final accounts = await _accounts.observeAccounts().first;
|
||||
final account = accounts.cast<Account?>().firstWhere(
|
||||
(a) => a?.id == accountId,
|
||||
orElse: () => null,
|
||||
);
|
||||
(a) => a?.id == accountId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (account == null) return;
|
||||
|
||||
final loop = switch (account.type) {
|
||||
AccountType.imap => _AccountSync(
|
||||
account,
|
||||
_accounts,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_imapConnect,
|
||||
_syncLog,
|
||||
_drafts,
|
||||
_onNewMail,
|
||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||
),
|
||||
account,
|
||||
_accounts,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_imapConnect,
|
||||
_syncLog,
|
||||
_drafts,
|
||||
_onNewMail,
|
||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||
),
|
||||
AccountType.jmap => _JmapAccountSync(
|
||||
account,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_accounts,
|
||||
_syncLog,
|
||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||
),
|
||||
account,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_accounts,
|
||||
_syncLog,
|
||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||
),
|
||||
};
|
||||
_active[accountId] = loop;
|
||||
loop.start();
|
||||
@@ -184,8 +184,8 @@ class _AccountSync implements _SyncLoop {
|
||||
this._onNewMail, {
|
||||
void Function()? onSyncStart,
|
||||
void Function()? onSyncEnd,
|
||||
}) : _onSyncStart = onSyncStart,
|
||||
_onSyncEnd = onSyncEnd;
|
||||
}) : _onSyncStart = onSyncStart,
|
||||
_onSyncEnd = onSyncEnd;
|
||||
|
||||
final Account account;
|
||||
final AccountRepository _accounts;
|
||||
@@ -379,8 +379,9 @@ class _AccountSync implements _SyncLoop {
|
||||
if (!_running) return;
|
||||
_stopSignal = Completer<void>();
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final username =
|
||||
account.username.isNotEmpty ? account.username : account.email;
|
||||
final username = account.username.isNotEmpty
|
||||
? account.username
|
||||
: account.email;
|
||||
final client = await _imapConnect(account, username, password);
|
||||
_idleClient = client;
|
||||
try {
|
||||
@@ -396,12 +397,13 @@ class _AccountSync implements _SyncLoop {
|
||||
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
|
||||
)
|
||||
.listen((e) {
|
||||
if (e is imap.ImapMessagesExistEvent &&
|
||||
e.newMessagesExists > e.oldMessagesExists) {
|
||||
hasNewMail = true;
|
||||
}
|
||||
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
||||
});
|
||||
if (e is imap.ImapMessagesExistEvent &&
|
||||
e.newMessagesExists > e.oldMessagesExists) {
|
||||
hasNewMail = true;
|
||||
}
|
||||
if (!newMessageCompleter.isCompleted)
|
||||
newMessageCompleter.complete();
|
||||
});
|
||||
|
||||
await client.idleStart();
|
||||
|
||||
@@ -443,8 +445,8 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
this._syncLog, {
|
||||
void Function()? onSyncStart,
|
||||
void Function()? onSyncEnd,
|
||||
}) : _onSyncStart = onSyncStart,
|
||||
_onSyncEnd = onSyncEnd;
|
||||
}) : _onSyncStart = onSyncStart,
|
||||
_onSyncEnd = onSyncEnd;
|
||||
|
||||
final Account account;
|
||||
final MailboxRepository _mailboxes;
|
||||
@@ -640,13 +642,15 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
// Try JMAP push (RFC 8887 EventSource). Falls back to poll timer when
|
||||
// the server doesn't advertise an eventSourceUrl or the connection fails.
|
||||
final pushReady = Completer<void>();
|
||||
final pushSub = _emails.watchJmapPush(account.id, password).listen(
|
||||
(_) {
|
||||
if (!pushReady.isCompleted) pushReady.complete();
|
||||
},
|
||||
onDone: () {},
|
||||
onError: (_) {},
|
||||
);
|
||||
final pushSub = _emails
|
||||
.watchJmapPush(account.id, password)
|
||||
.listen(
|
||||
(_) {
|
||||
if (!pushReady.isCompleted) pushReady.complete();
|
||||
},
|
||||
onDone: () {},
|
||||
onError: (_) {},
|
||||
);
|
||||
|
||||
final pollTimer = Timer(_pollInterval, () {
|
||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||
|
||||
@@ -83,8 +83,9 @@ Future<void> _checkAccount(
|
||||
) async {
|
||||
try {
|
||||
final password = await accountRepo.getPassword(account.id);
|
||||
final username =
|
||||
account.username.isNotEmpty ? account.username : account.email;
|
||||
final username = account.username.isNotEmpty
|
||||
? account.username
|
||||
: account.email;
|
||||
final client = await connectImap(account, username, password);
|
||||
try {
|
||||
final status = await client.statusMailbox(
|
||||
@@ -93,16 +94,18 @@ Future<void> _checkAccount(
|
||||
);
|
||||
final currentUidNext = status.uidNext;
|
||||
|
||||
final stored = await (db.select(db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.resourceType.equals(_kResourceType),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
final stored =
|
||||
await (db.select(db.syncStates)..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.resourceType.equals(_kResourceType),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
final lastUidNext = _parseUidNext(stored?.state);
|
||||
|
||||
await db.into(db.syncStates).insertOnConflictUpdate(
|
||||
await db
|
||||
.into(db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: account.id,
|
||||
resourceType: _kResourceType,
|
||||
|
||||
@@ -76,11 +76,14 @@ class ReliabilityRunner {
|
||||
}
|
||||
}
|
||||
|
||||
final isHealthy = totalMissingLocally == 0 &&
|
||||
final isHealthy =
|
||||
totalMissingLocally == 0 &&
|
||||
totalMissingOnServer == 0 &&
|
||||
totalFlagMismatches == 0;
|
||||
|
||||
await _db.into(_db.syncHealth).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.syncHealth)
|
||||
.insertOnConflictUpdate(
|
||||
SyncHealthCompanion.insert(
|
||||
accountId: accountId,
|
||||
lastVerifiedAt: DateTime.now(),
|
||||
|
||||
@@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
|
||||
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
||||
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
||||
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
||||
.replaceAll(
|
||||
"src='cid:${bareCid.toLowerCase()}'",
|
||||
"src='$dataUri'",
|
||||
);
|
||||
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+216
-218
@@ -388,231 +388,228 @@ class AppDatabase extends _$AppDatabase {
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) async {
|
||||
await m.createAll();
|
||||
await _createEmailFts();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// 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
|
||||
// addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`.
|
||||
if (from < 2) {
|
||||
await m.addColumn(accounts, accounts.accountType);
|
||||
await m.addColumn(accounts, accounts.jmapUrl);
|
||||
}
|
||||
if (from < 3) {
|
||||
await m.addColumn(accounts, accounts.username);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.createTable(drafts);
|
||||
}
|
||||
if (from < 5) {
|
||||
await m.createTable(syncStates);
|
||||
}
|
||||
if (from < 6) {
|
||||
await m.createTable(pendingChanges);
|
||||
}
|
||||
if (from < 7) {
|
||||
await m.createTable(syncLogs);
|
||||
}
|
||||
if (from < 8) {
|
||||
await m.addColumn(mailboxes, mailboxes.role);
|
||||
}
|
||||
if (from < 9) {
|
||||
await m.addColumn(emailBodies, emailBodies.cachedAt);
|
||||
}
|
||||
if (from >= 7 && from < 10) {
|
||||
await m.addColumn(syncLogs, syncLogs.protocol);
|
||||
await m.addColumn(syncLogs, syncLogs.mailboxesSynced);
|
||||
await m.addColumn(syncLogs, syncLogs.pendingFlushed);
|
||||
}
|
||||
if (from >= 7 && from < 11) {
|
||||
await m.addColumn(syncLogs, syncLogs.emailsSkipped);
|
||||
await m.addColumn(syncLogs, syncLogs.bytesTransferred);
|
||||
}
|
||||
if (from < 12) {
|
||||
await m.createTable(syncLogMailboxes);
|
||||
}
|
||||
if (from < 13) {
|
||||
await m.addColumn(accounts, accounts.verbose);
|
||||
if (from >= 7) {
|
||||
await m.addColumn(syncLogs, syncLogs.protocolLog);
|
||||
}
|
||||
}
|
||||
if (from < 14) {
|
||||
await m.addColumn(emails, emails.threadId);
|
||||
await m.addColumn(emails, emails.messageId);
|
||||
await m.addColumn(emails, emails.inReplyTo);
|
||||
await m.addColumn(emails, emails.references);
|
||||
}
|
||||
if (from < 15) {
|
||||
await m.addColumn(accounts, accounts.manageSieveHost);
|
||||
await m.addColumn(accounts, accounts.manageSievePort);
|
||||
await m.addColumn(accounts, accounts.manageSieveSsl);
|
||||
}
|
||||
if (from < 16) {
|
||||
await m.addColumn(accounts, accounts.manageSieveAvailable);
|
||||
}
|
||||
if (from < 17) {
|
||||
await m.createTable(threads);
|
||||
// Populate threads from existing emails.
|
||||
final allRows = await select(emails).get();
|
||||
final groups = <String, List<Email>>{};
|
||||
for (final row in allRows) {
|
||||
final key =
|
||||
'${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}';
|
||||
groups.putIfAbsent(key, () => []).add(row);
|
||||
}
|
||||
onCreate: (m) async {
|
||||
await m.createAll();
|
||||
await _createEmailFts();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// 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
|
||||
// addColumn(T, T.C) with `if (from >= creationVersionOfT && from < X)`.
|
||||
if (from < 2) {
|
||||
await m.addColumn(accounts, accounts.accountType);
|
||||
await m.addColumn(accounts, accounts.jmapUrl);
|
||||
}
|
||||
if (from < 3) {
|
||||
await m.addColumn(accounts, accounts.username);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.createTable(drafts);
|
||||
}
|
||||
if (from < 5) {
|
||||
await m.createTable(syncStates);
|
||||
}
|
||||
if (from < 6) {
|
||||
await m.createTable(pendingChanges);
|
||||
}
|
||||
if (from < 7) {
|
||||
await m.createTable(syncLogs);
|
||||
}
|
||||
if (from < 8) {
|
||||
await m.addColumn(mailboxes, mailboxes.role);
|
||||
}
|
||||
if (from < 9) {
|
||||
await m.addColumn(emailBodies, emailBodies.cachedAt);
|
||||
}
|
||||
if (from >= 7 && from < 10) {
|
||||
await m.addColumn(syncLogs, syncLogs.protocol);
|
||||
await m.addColumn(syncLogs, syncLogs.mailboxesSynced);
|
||||
await m.addColumn(syncLogs, syncLogs.pendingFlushed);
|
||||
}
|
||||
if (from >= 7 && from < 11) {
|
||||
await m.addColumn(syncLogs, syncLogs.emailsSkipped);
|
||||
await m.addColumn(syncLogs, syncLogs.bytesTransferred);
|
||||
}
|
||||
if (from < 12) {
|
||||
await m.createTable(syncLogMailboxes);
|
||||
}
|
||||
if (from < 13) {
|
||||
await m.addColumn(accounts, accounts.verbose);
|
||||
if (from >= 7) {
|
||||
await m.addColumn(syncLogs, syncLogs.protocolLog);
|
||||
}
|
||||
}
|
||||
if (from < 14) {
|
||||
await m.addColumn(emails, emails.threadId);
|
||||
await m.addColumn(emails, emails.messageId);
|
||||
await m.addColumn(emails, emails.inReplyTo);
|
||||
await m.addColumn(emails, emails.references);
|
||||
}
|
||||
if (from < 15) {
|
||||
await m.addColumn(accounts, accounts.manageSieveHost);
|
||||
await m.addColumn(accounts, accounts.manageSievePort);
|
||||
await m.addColumn(accounts, accounts.manageSieveSsl);
|
||||
}
|
||||
if (from < 16) {
|
||||
await m.addColumn(accounts, accounts.manageSieveAvailable);
|
||||
}
|
||||
if (from < 17) {
|
||||
await m.createTable(threads);
|
||||
// Populate threads from existing emails.
|
||||
final allRows = await select(emails).get();
|
||||
final groups = <String, List<Email>>{};
|
||||
for (final row in allRows) {
|
||||
final key =
|
||||
'${row.accountId}:${row.mailboxPath}:${row.threadId ?? row.id}';
|
||||
groups.putIfAbsent(key, () => []).add(row);
|
||||
}
|
||||
|
||||
for (final threadEmails in groups.values) {
|
||||
threadEmails.sort((a, b) {
|
||||
final da = a.sentAt ?? a.receivedAt;
|
||||
final db = b.sentAt ?? b.receivedAt;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
final latest = threadEmails.last;
|
||||
for (final threadEmails in groups.values) {
|
||||
threadEmails.sort((a, b) {
|
||||
final da = a.sentAt ?? a.receivedAt;
|
||||
final db = b.sentAt ?? b.receivedAt;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
final latest = threadEmails.last;
|
||||
|
||||
await into(threads).insert(
|
||||
ThreadsCompanion.insert(
|
||||
id: latest.threadId ?? latest.id,
|
||||
accountId: latest.accountId,
|
||||
mailboxPath: latest.mailboxPath,
|
||||
subject: Value(latest.subject),
|
||||
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||
messageCount: Value(threadEmails.length),
|
||||
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||
preview: Value(latest.preview),
|
||||
latestEmailId: latest.id,
|
||||
emailIdsJson: Value(
|
||||
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);',
|
||||
await into(threads).insert(
|
||||
ThreadsCompanion.insert(
|
||||
id: latest.threadId ?? latest.id,
|
||||
accountId: latest.accountId,
|
||||
mailboxPath: latest.mailboxPath,
|
||||
subject: Value(latest.subject),
|
||||
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||
messageCount: Value(threadEmails.length),
|
||||
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||
preview: Value(latest.preview),
|
||||
latestEmailId: latest.id,
|
||||
emailIdsJson: Value(
|
||||
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||
),
|
||||
);
|
||||
// Index for finding emails in a thread.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'emails_thread_id',
|
||||
'CREATE INDEX emails_thread_id ON emails (account_id, mailbox_path, thread_id);',
|
||||
),
|
||||
);
|
||||
// Index for pending changes queue.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'pending_changes_account_id',
|
||||
'CREATE INDEX pending_changes_account_id ON pending_changes (account_id);',
|
||||
),
|
||||
);
|
||||
}
|
||||
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();
|
||||
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);',
|
||||
),
|
||||
);
|
||||
// Index for finding emails in a thread.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'emails_thread_id',
|
||||
'CREATE INDEX emails_thread_id ON emails (account_id, mailbox_path, thread_id);',
|
||||
),
|
||||
);
|
||||
// Index for pending changes queue.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'pending_changes_account_id',
|
||||
'CREATE INDEX pending_changes_account_id ON pending_changes (account_id);',
|
||||
),
|
||||
);
|
||||
}
|
||||
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')) {
|
||||
await m.addColumn(emails, emails.snoozedUntil);
|
||||
}
|
||||
if (!names.contains('snoozed_from_mailbox_path')) {
|
||||
await m.addColumn(emails, emails.snoozedFromMailboxPath);
|
||||
}
|
||||
if (!names.contains('snoozed_until')) {
|
||||
await m.addColumn(emails, emails.snoozedUntil);
|
||||
}
|
||||
if (!names.contains('snoozed_from_mailbox_path')) {
|
||||
await m.addColumn(emails, emails.snoozedFromMailboxPath);
|
||||
}
|
||||
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'emails_snoozed_until',
|
||||
'CREATE INDEX IF NOT EXISTS emails_snoozed_until ON emails (account_id, snoozed_until) WHERE snoozed_until IS NOT NULL;',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (from < 23) {
|
||||
await m.addColumn(emails, emails.listUnsubscribeHeader);
|
||||
}
|
||||
if (from >= 4 && from < 24) {
|
||||
await m.addColumn(drafts, drafts.imapServerId);
|
||||
}
|
||||
if (from < 25) {
|
||||
// For observeMailboxes: filter by account_id, sort by path.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'mailboxes_account_id',
|
||||
'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.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'threads_latest_date',
|
||||
'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (from < 26) {
|
||||
await _createEmailFts();
|
||||
// Backfill FTS index from existing rows.
|
||||
await customStatement('''
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'emails_snoozed_until',
|
||||
'CREATE INDEX IF NOT EXISTS emails_snoozed_until ON emails (account_id, snoozed_until) WHERE snoozed_until IS NOT NULL;',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (from < 23) {
|
||||
await m.addColumn(emails, emails.listUnsubscribeHeader);
|
||||
}
|
||||
if (from >= 4 && from < 24) {
|
||||
await m.addColumn(drafts, drafts.imapServerId);
|
||||
}
|
||||
if (from < 25) {
|
||||
// For observeMailboxes: filter by account_id, sort by path.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'mailboxes_account_id',
|
||||
'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.
|
||||
await m.createIndex(
|
||||
Index(
|
||||
'threads_latest_date',
|
||||
'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (from < 26) {
|
||||
await _createEmailFts();
|
||||
// Backfill FTS index from existing rows.
|
||||
await customStatement('''
|
||||
INSERT INTO email_fts(rowid, subject, preview, from_json)
|
||||
SELECT rowid, subject, preview, from_json FROM emails
|
||||
''');
|
||||
}
|
||||
if (from < 27) {
|
||||
await m.createTable(searchHistoryEntries);
|
||||
}
|
||||
if (from < 28) {
|
||||
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
|
||||
}
|
||||
if (from < 29) {
|
||||
await m.createTable(localSieveScripts);
|
||||
}
|
||||
if (from >= 12 && from < 30) {
|
||||
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
|
||||
}
|
||||
if (from < 31) {
|
||||
await m.createTable(shareKeys);
|
||||
}
|
||||
if (from < 32) {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
if (from < 34) {
|
||||
await m.createTable(userPreferences);
|
||||
}
|
||||
if (from >= 34 && from < 35) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.mailViewButtonPosition,
|
||||
);
|
||||
}
|
||||
if (from >= 34 && from < 36) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.afterMailViewAction,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (from < 27) {
|
||||
await m.createTable(searchHistoryEntries);
|
||||
}
|
||||
if (from < 28) {
|
||||
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
|
||||
}
|
||||
if (from < 29) {
|
||||
await m.createTable(localSieveScripts);
|
||||
}
|
||||
if (from >= 12 && from < 30) {
|
||||
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
|
||||
}
|
||||
if (from < 31) {
|
||||
await m.createTable(shareKeys);
|
||||
}
|
||||
if (from < 32) {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
if (from < 34) {
|
||||
await m.createTable(userPreferences);
|
||||
}
|
||||
if (from >= 34 && from < 35) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.mailViewButtonPosition,
|
||||
);
|
||||
}
|
||||
if (from >= 34 && from < 36) {
|
||||
await m.addColumn(userPreferences, userPreferences.afterMailViewAction);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved once in main() via initDatabasePath() before runApp().
|
||||
@@ -663,7 +660,8 @@ Future<String> _resolveDatabasePath() async {
|
||||
}
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||
message:
|
||||
'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||
'cannot open database.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ class LocalSieveRepository {
|
||||
final AppDatabase _db;
|
||||
|
||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||
final rows = await (_db.select(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
final rows = await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.accountId.equals(accountId))).get();
|
||||
return rows
|
||||
.map(
|
||||
(r) => SieveScript(
|
||||
@@ -26,11 +26,11 @@ class LocalSieveRepository {
|
||||
|
||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||
final rowId = int.parse(blobId);
|
||||
final row = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
final row =
|
||||
await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw Exception('Local script not found: $blobId');
|
||||
return row.content;
|
||||
}
|
||||
@@ -44,20 +44,18 @@ class LocalSieveRepository {
|
||||
if (id != null) {
|
||||
final rowId = int.parse(id);
|
||||
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(
|
||||
LocalSieveScriptsCompanion(
|
||||
name: Value(name),
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final updated = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
LocalSieveScriptsCompanion(
|
||||
name: Value(name),
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final updated =
|
||||
await (_db.select(_db.localSieveScripts)..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return SieveScript(
|
||||
id: id,
|
||||
name: name,
|
||||
@@ -65,7 +63,9 @@ class LocalSieveRepository {
|
||||
isActive: updated?.isActive ?? false,
|
||||
);
|
||||
}
|
||||
final rowId = await _db.into(_db.localSieveScripts).insert(
|
||||
final rowId = await _db
|
||||
.into(_db.localSieveScripts)
|
||||
.insert(
|
||||
LocalSieveScriptsCompanion.insert(
|
||||
accountId: accountId,
|
||||
name: name,
|
||||
@@ -78,11 +78,9 @@ class LocalSieveRepository {
|
||||
|
||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||
final rowId = int.parse(scriptId);
|
||||
await (_db.delete(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))).go();
|
||||
}
|
||||
|
||||
Future<void> activateScript(String accountId, String scriptId) async {
|
||||
@@ -92,9 +90,7 @@ class LocalSieveRepository {
|
||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||
final rowId = int.parse(scriptId);
|
||||
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(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/utils/host_utils.dart';
|
||||
import 'package:sharedinbox/data/imap/tls_error.dart';
|
||||
|
||||
typedef ImapConnectFn = Future<ImapClient> Function(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
);
|
||||
typedef ImapConnectFn =
|
||||
Future<ImapClient> Function(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
);
|
||||
|
||||
/// Zone value key signalling that a [StringBuffer] for protocol logging is
|
||||
/// active. When this key is non-null in the current zone, [connectImap]
|
||||
@@ -64,8 +65,9 @@ Future<SmtpClient> connectSmtp(
|
||||
// clientDomain is the sending domain advertised in EHLO — use the host part
|
||||
// of the sender email, falling back to the SMTP host.
|
||||
final atIndex = account.email.lastIndexOf('@');
|
||||
final clientDomain =
|
||||
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
|
||||
final clientDomain = atIndex != -1
|
||||
? account.email.substring(atIndex + 1)
|
||||
: account.smtpHost;
|
||||
|
||||
if (!account.smtpSsl && !isLocalhost(account.smtpHost)) {
|
||||
throw Exception(
|
||||
|
||||
@@ -26,14 +26,14 @@ class JmapClient {
|
||||
String? uploadUrl,
|
||||
String? downloadUrl,
|
||||
String? eventSourceUrl,
|
||||
}) : _httpClient = httpClient,
|
||||
_credentials = credentials,
|
||||
_apiUrl = apiUrl,
|
||||
_accountId = accountId,
|
||||
_capabilities = capabilities,
|
||||
_uploadUrl = uploadUrl,
|
||||
_downloadUrl = downloadUrl,
|
||||
_eventSourceUrl = eventSourceUrl;
|
||||
}) : _httpClient = httpClient,
|
||||
_credentials = credentials,
|
||||
_apiUrl = apiUrl,
|
||||
_accountId = accountId,
|
||||
_capabilities = capabilities,
|
||||
_uploadUrl = uploadUrl,
|
||||
_downloadUrl = downloadUrl,
|
||||
_eventSourceUrl = eventSourceUrl;
|
||||
|
||||
final http.Client _httpClient;
|
||||
final String _credentials;
|
||||
@@ -67,12 +67,9 @@ class JmapClient {
|
||||
http.Response resp;
|
||||
var attempt = 0;
|
||||
while (true) {
|
||||
resp = await httpClient.get(
|
||||
jmapUrl,
|
||||
headers: {
|
||||
'Authorization': 'Basic $credentials',
|
||||
},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
resp = await httpClient
|
||||
.get(jmapUrl, headers: {'Authorization': 'Basic $credentials'})
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (resp.statusCode != 429 || attempt >= 4) {
|
||||
break;
|
||||
}
|
||||
@@ -218,12 +215,9 @@ class JmapClient {
|
||||
.replaceAll('{name}', Uri.encodeComponent(name))
|
||||
.replaceAll('{type}', Uri.encodeComponent(type)),
|
||||
);
|
||||
final resp = await _httpClient.get(
|
||||
url,
|
||||
headers: {
|
||||
'Authorization': 'Basic $_credentials',
|
||||
},
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final resp = await _httpClient
|
||||
.get(url, headers: {'Authorization': 'Basic $_credentials'})
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (resp.statusCode != 200) {
|
||||
throw JmapException('Blob download failed (HTTP ${resp.statusCode})');
|
||||
}
|
||||
@@ -246,7 +240,8 @@ class JmapClient {
|
||||
|
||||
static String _extractAccountId(Map<String, dynamic> session) {
|
||||
final primaryAccounts = session['primaryAccounts'] as Map<String, dynamic>?;
|
||||
final id = primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ??
|
||||
final id =
|
||||
primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ??
|
||||
primaryAccounts?['urn:ietf:params:jmap:core'] as String?;
|
||||
if (id != null) return id;
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/data/imap/managesieve_client.dart';
|
||||
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||
|
||||
typedef ManageSieveConnectFn = Future<ManageSieveClient> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
});
|
||||
typedef ManageSieveConnectFn =
|
||||
Future<ManageSieveClient> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
});
|
||||
|
||||
Future<ManageSieveClient> _defaultManageSieveConnect({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) =>
|
||||
ManageSieveClient.connect(host: host, port: port, useTls: useTls);
|
||||
}) => ManageSieveClient.connect(host: host, port: port, useTls: useTls);
|
||||
|
||||
class SieveRepository {
|
||||
SieveRepository(
|
||||
@@ -51,16 +51,13 @@ class SieveRepository {
|
||||
});
|
||||
}
|
||||
return _withJmap(account, (jmap) async {
|
||||
final responses = await jmap.call(
|
||||
final responses = await jmap.call([
|
||||
[
|
||||
[
|
||||
'SieveScript/get',
|
||||
{'accountId': jmap.accountId, 'ids': null},
|
||||
'0',
|
||||
],
|
||||
'SieveScript/get',
|
||||
{'accountId': jmap.accountId, 'ids': null},
|
||||
'0',
|
||||
],
|
||||
withSieve: true,
|
||||
);
|
||||
], withSieve: true);
|
||||
final result = _responseArgs(responses, 0, 'SieveScript/get');
|
||||
final list = result['list'] as List<dynamic>;
|
||||
return list.map((e) {
|
||||
@@ -126,12 +123,9 @@ class SieveRepository {
|
||||
id: {'name': name, 'blobId': blobId},
|
||||
},
|
||||
};
|
||||
final responses = await jmap.call(
|
||||
[
|
||||
['SieveScript/set', setArgs, '0'],
|
||||
],
|
||||
withSieve: true,
|
||||
);
|
||||
final responses = await jmap.call([
|
||||
['SieveScript/set', setArgs, '0'],
|
||||
], withSieve: true);
|
||||
final result = _responseArgs(responses, 0, 'SieveScript/set');
|
||||
if (id == null) {
|
||||
final created = result['created'] as Map<String, dynamic>?;
|
||||
@@ -170,19 +164,16 @@ class SieveRepository {
|
||||
return;
|
||||
}
|
||||
await _withJmap(account, (jmap) async {
|
||||
final responses = await jmap.call(
|
||||
final responses = await jmap.call([
|
||||
[
|
||||
[
|
||||
'SieveScript/set',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'destroy': [scriptId],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
'SieveScript/set',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'destroy': [scriptId],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
withSieve: true,
|
||||
);
|
||||
], withSieve: true);
|
||||
final result = _responseArgs(responses, 0, 'SieveScript/set');
|
||||
final notDestroyed = result['notDestroyed'] as Map<String, dynamic>?;
|
||||
if (notDestroyed != null && notDestroyed.containsKey(scriptId)) {
|
||||
@@ -201,16 +192,13 @@ class SieveRepository {
|
||||
return;
|
||||
}
|
||||
await _withJmap(account, (jmap) async {
|
||||
await jmap.call(
|
||||
await jmap.call([
|
||||
[
|
||||
[
|
||||
'SieveScript/activate',
|
||||
{'accountId': jmap.accountId, 'id': scriptId},
|
||||
'0',
|
||||
],
|
||||
'SieveScript/activate',
|
||||
{'accountId': jmap.accountId, 'id': scriptId},
|
||||
'0',
|
||||
],
|
||||
withSieve: true,
|
||||
);
|
||||
], withSieve: true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,8 +219,9 @@ class SieveRepository {
|
||||
throw Exception('Account has no JMAP URL');
|
||||
}
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final username =
|
||||
account.username.isNotEmpty ? account.username : account.email;
|
||||
final username = account.username.isNotEmpty
|
||||
? account.username
|
||||
: account.email;
|
||||
final jmap = await JmapClient.connect(
|
||||
httpClient: _httpClient,
|
||||
jmapUrl: Uri.parse(jmapUrl),
|
||||
@@ -258,8 +247,9 @@ class SieveRepository {
|
||||
throw Exception('Account has no ManageSieve host configured');
|
||||
}
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final username =
|
||||
account.username.isNotEmpty ? account.username : account.email;
|
||||
final username = account.username.isNotEmpty
|
||||
? account.username
|
||||
: account.email;
|
||||
final client = await _manageSieveConnect(
|
||||
host: host,
|
||||
port: account.manageSievePort,
|
||||
|
||||
@@ -23,14 +23,15 @@ class AccountRepositoryImpl implements AccountRepository {
|
||||
Future<model.Account?> getAccount(String id) async {
|
||||
final row = await (_db.select(
|
||||
_db.accounts,
|
||||
)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addAccount(model.Account account, String password) async {
|
||||
await _db.into(_db.accounts).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.accounts)
|
||||
.insertOnConflictUpdate(
|
||||
AccountsCompanion.insert(
|
||||
id: account.id,
|
||||
displayName: account.displayName,
|
||||
@@ -58,8 +59,7 @@ class AccountRepositoryImpl implements AccountRepository {
|
||||
Future<void> updateAccount(model.Account account, {String? password}) async {
|
||||
await (_db.update(
|
||||
_db.accounts,
|
||||
)..where((t) => t.id.equals(account.id)))
|
||||
.write(
|
||||
)..where((t) => t.id.equals(account.id))).write(
|
||||
AccountsCompanion(
|
||||
displayName: Value(account.displayName),
|
||||
email: Value(account.email),
|
||||
@@ -102,22 +102,22 @@ class AccountRepositoryImpl implements AccountRepository {
|
||||
String _passwordKey(String accountId) => 'account_password_$accountId';
|
||||
|
||||
model.Account _toModel(Account row) => model.Account(
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
username: row.username,
|
||||
type: model.AccountType.values.byName(row.accountType),
|
||||
imapHost: row.imapHost,
|
||||
imapPort: row.imapPort,
|
||||
imapSsl: row.imapSsl,
|
||||
smtpHost: row.smtpHost,
|
||||
smtpPort: row.smtpPort,
|
||||
smtpSsl: row.smtpSsl,
|
||||
manageSieveHost: row.manageSieveHost,
|
||||
manageSievePort: row.manageSievePort,
|
||||
manageSieveSsl: row.manageSieveSsl,
|
||||
manageSieveAvailable: row.manageSieveAvailable,
|
||||
jmapUrl: row.jmapUrl,
|
||||
verbose: row.verbose,
|
||||
);
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
username: row.username,
|
||||
type: model.AccountType.values.byName(row.accountType),
|
||||
imapHost: row.imapHost,
|
||||
imapPort: row.imapPort,
|
||||
imapSsl: row.imapSsl,
|
||||
smtpHost: row.smtpHost,
|
||||
smtpPort: row.smtpPort,
|
||||
smtpSsl: row.smtpSsl,
|
||||
manageSieveHost: row.manageSieveHost,
|
||||
manageSievePort: row.manageSievePort,
|
||||
manageSieveSsl: row.manageSieveSsl,
|
||||
manageSieveAvailable: row.manageSieveAvailable,
|
||||
jmapUrl: row.jmapUrl,
|
||||
verbose: row.verbose,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
|
||||
class DraftRepositoryImpl implements DraftRepository {
|
||||
DraftRepositoryImpl(
|
||||
this._db,
|
||||
this._accounts, {
|
||||
ImapConnectFn? imapConnect,
|
||||
}) : _imapConnect = imapConnect;
|
||||
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
||||
: _imapConnect = imapConnect;
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accounts;
|
||||
@@ -54,7 +51,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
);
|
||||
}
|
||||
|
||||
final newId = await _db.into(_db.drafts).insert(
|
||||
final newId = await _db
|
||||
.into(_db.drafts)
|
||||
.insert(
|
||||
DraftsCompanion.insert(
|
||||
accountId: Value(accountId),
|
||||
replyToEmailId: Value(replyToEmailId),
|
||||
@@ -95,8 +94,7 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
Future<SavedDraft?> getDraft(int id) async {
|
||||
final row = await (_db.select(
|
||||
_db.drafts,
|
||||
)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@@ -113,8 +111,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
final account = await _accounts.getAccount(accountId);
|
||||
if (account == null || account.type != AccountType.imap) return;
|
||||
|
||||
final username =
|
||||
account.username.isNotEmpty ? account.username : account.email;
|
||||
final username = account.username.isNotEmpty
|
||||
? account.username
|
||||
: account.email;
|
||||
imap.ImapClient? client;
|
||||
try {
|
||||
client = await connect(account, username, password);
|
||||
@@ -124,10 +123,7 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncWithServer(
|
||||
imap.ImapClient client,
|
||||
String accountId,
|
||||
) async {
|
||||
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
||||
// Create/select the Drafts folder.
|
||||
try {
|
||||
await client.createMailbox('Drafts');
|
||||
@@ -138,11 +134,11 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
final messageCount = selectResult.messagesExists;
|
||||
|
||||
// Upload local drafts that have no server counterpart.
|
||||
final localDrafts = await (_db.select(_db.drafts)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
|
||||
))
|
||||
.get();
|
||||
final localDrafts =
|
||||
await (_db.select(_db.drafts)..where(
|
||||
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in localDrafts) {
|
||||
final builder = imap.MessageBuilder()
|
||||
@@ -156,24 +152,26 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
targetMailboxPath: 'Drafts',
|
||||
flags: [r'\Draft'],
|
||||
);
|
||||
final uidList =
|
||||
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||
final uidList = appendResult.responseCodeAppendUid?.targetSequence
|
||||
.toList();
|
||||
final uid = (uidList != null && uidList.isNotEmpty)
|
||||
? uidList.first.toString()
|
||||
: null;
|
||||
if (uid != null) {
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
||||
DraftsCompanion(imapServerId: Value(uid)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Download server drafts not tracked locally.
|
||||
if (messageCount > 0) {
|
||||
final knownServerIds = await (_db.select(_db.drafts)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
|
||||
))
|
||||
.get();
|
||||
final knownServerIds =
|
||||
await (_db.select(_db.drafts)..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
|
||||
))
|
||||
.get();
|
||||
final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet();
|
||||
|
||||
final seq = imap.MessageSequence.fromAll();
|
||||
@@ -184,7 +182,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
if (msg.flags?.contains(r'\Deleted') ?? false) continue;
|
||||
final env = msg.envelope;
|
||||
final now = DateTime.now();
|
||||
await _db.into(_db.drafts).insert(
|
||||
await _db
|
||||
.into(_db.drafts)
|
||||
.insert(
|
||||
DraftsCompanion.insert(
|
||||
accountId: Value(accountId),
|
||||
toText: Value(_addressListToText(env?.to)),
|
||||
@@ -210,14 +210,14 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
}
|
||||
|
||||
SavedDraft _toModel(Draft row) => SavedDraft(
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
replyToEmailId: row.replyToEmailId,
|
||||
toText: row.toText,
|
||||
ccText: row.ccText,
|
||||
subjectText: row.subjectText,
|
||||
bodyText: row.bodyText,
|
||||
updatedAt: row.updatedAt,
|
||||
imapServerId: row.imapServerId,
|
||||
);
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
replyToEmailId: row.replyToEmailId,
|
||||
toText: row.toText,
|
||||
ccText: row.ccText,
|
||||
subjectText: row.subjectText,
|
||||
bodyText: row.bodyText,
|
||||
updatedAt: row.updatedAt,
|
||||
imapServerId: row.imapServerId,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
this._accounts, {
|
||||
ImapConnectFn imapConnect = connectImap,
|
||||
http.Client? httpClient,
|
||||
}) : _imapConnect = imapConnect,
|
||||
_httpClient = httpClient ?? http.Client();
|
||||
}) : _imapConnect = imapConnect,
|
||||
_httpClient = httpClient ?? http.Client();
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accounts;
|
||||
@@ -45,12 +45,13 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
String accountId,
|
||||
String role,
|
||||
) async {
|
||||
final row = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals(role),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final row =
|
||||
await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals(role),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@@ -82,9 +83,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
|
||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||
// folders the server doesn't tag with a special-use attribute.
|
||||
final existingRows = await (_db.select(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(account.id)))
|
||||
.get();
|
||||
final existingRows = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.accountId.equals(account.id))).get();
|
||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||
|
||||
for (final mb in mailboxes) {
|
||||
@@ -110,7 +111,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
// when the IMAP server does not expose a special-use attribute.
|
||||
final role = _imapRole(mb) ?? existingRoles[id];
|
||||
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: account.id,
|
||||
@@ -215,8 +218,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
for (final jmapId in destroyed) {
|
||||
await (_db.delete(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals('$accountId:$jmapId')))
|
||||
.go();
|
||||
)..where((t) => t.id.equals('$accountId:$jmapId'))).go();
|
||||
}
|
||||
|
||||
await _saveSyncState(accountId, 'Mailbox', newState);
|
||||
@@ -237,7 +239,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
final dbId = '$accountId:$jmapId';
|
||||
// For JMAP accounts, path stores the JMAP mailbox ID so that
|
||||
// Email rows can reference it via mailboxPath.
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: dbId,
|
||||
accountId: accountId,
|
||||
@@ -254,13 +258,13 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
// ── sync_state helpers ────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _loadSyncState(String accountId, String resourceType) async {
|
||||
final row = await (_db.select(_db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.resourceType.equals(resourceType),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
final row =
|
||||
await (_db.select(_db.syncStates)..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.resourceType.equals(resourceType),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return row?.state;
|
||||
}
|
||||
|
||||
@@ -269,7 +273,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
String resourceType,
|
||||
String state,
|
||||
) async {
|
||||
await _db.into(_db.syncStates).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: accountId,
|
||||
resourceType: resourceType,
|
||||
@@ -298,14 +304,14 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
}
|
||||
|
||||
model.Mailbox _toModel(MailboxRow row) => model.Mailbox(
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
path: row.path,
|
||||
name: row.name,
|
||||
unreadCount: row.unreadCount,
|
||||
totalCount: row.totalCount,
|
||||
role: row.role,
|
||||
);
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
path: row.path,
|
||||
name: row.name,
|
||||
unreadCount: row.unreadCount,
|
||||
totalCount: row.totalCount,
|
||||
role: row.role,
|
||||
);
|
||||
|
||||
/// Maps enough_mail special-use flags (RFC 6154) to JMAP role strings (RFC 8621).
|
||||
static String? _imapRole(imap.Mailbox mb) {
|
||||
@@ -320,9 +326,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {
|
||||
await (_db.delete(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.accountId.equals(accountId))).go();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -358,7 +364,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
await client.logout();
|
||||
}
|
||||
final id = '${account.id}:$name';
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: account.id,
|
||||
@@ -367,8 +375,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
|
||||
.getSingle();
|
||||
final row = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals(id))).getSingle();
|
||||
return _toModel(row);
|
||||
}
|
||||
|
||||
@@ -410,7 +419,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
);
|
||||
}
|
||||
final dbId = '${account.id}:$newId';
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: dbId,
|
||||
accountId: account.id,
|
||||
@@ -419,9 +430,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
final row = await (_db.select(_db.mailboxes)
|
||||
..where((t) => t.id.equals(dbId)))
|
||||
.getSingle();
|
||||
final row = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals(dbId))).getSingle();
|
||||
return _toModel(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
|
||||
@override
|
||||
Future<List<String>> getRecentSearches() async {
|
||||
final rows = await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.get();
|
||||
final rows =
|
||||
await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.get();
|
||||
return rows.map((r) => r.query).toList();
|
||||
}
|
||||
|
||||
@@ -24,11 +25,13 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
|
||||
await _db.transaction(() async {
|
||||
// Remove existing entry for same query (deduplication).
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.query.equals(trimmed)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.searchHistoryEntries,
|
||||
)..where((t) => t.query.equals(trimmed))).go();
|
||||
|
||||
await _db.into(_db.searchHistoryEntries).insert(
|
||||
await _db
|
||||
.into(_db.searchHistoryEntries)
|
||||
.insert(
|
||||
SearchHistoryEntriesCompanion.insert(
|
||||
query: trimmed,
|
||||
searchedAt: DateTime.now(),
|
||||
@@ -36,16 +39,17 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
);
|
||||
|
||||
// Prune to the most recent _maxEntries.
|
||||
final keepIds = await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.map((r) => r.id)
|
||||
.get();
|
||||
final keepIds =
|
||||
await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.map((r) => r.id)
|
||||
.get();
|
||||
|
||||
if (keepIds.isNotEmpty) {
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.id.isNotIn(keepIds)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.searchHistoryEntries,
|
||||
)..where((t) => t.id.isNotIn(keepIds))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
final keyIdHex = _hex(material.keyId);
|
||||
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
|
||||
await _db.into(_db.shareKeys).insert(
|
||||
await _db
|
||||
.into(_db.shareKeys)
|
||||
.insert(
|
||||
ShareKeysCompanion.insert(
|
||||
id: keyIdHex,
|
||||
publicKey: base64.encode(material.publicKeyBytes),
|
||||
@@ -40,9 +42,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
await _pruneExpired();
|
||||
|
||||
final keyIdHex = _hex(keyId);
|
||||
final row = await (_db.select(_db.shareKeys)
|
||||
..where((t) => t.id.equals(keyIdHex)))
|
||||
.getSingleOrNull();
|
||||
final row = await (_db.select(
|
||||
_db.shareKeys,
|
||||
)..where((t) => t.id.equals(keyIdHex))).getSingleOrNull();
|
||||
|
||||
if (row == null) return null;
|
||||
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
|
||||
@@ -55,10 +57,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
}
|
||||
|
||||
Future<void> _pruneExpired() async {
|
||||
await (_db.delete(_db.shareKeys)
|
||||
..where(
|
||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||
))
|
||||
await (_db.delete(
|
||||
_db.shareKeys,
|
||||
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
||||
.go();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
String? protocolLog,
|
||||
}) async {
|
||||
await _db.transaction(() async {
|
||||
final logId = await _db.into(_db.syncLogs).insert(
|
||||
final logId = await _db
|
||||
.into(_db.syncLogs)
|
||||
.insert(
|
||||
SyncLogsCompanion.insert(
|
||||
accountId: accountId,
|
||||
result: success ? 'ok' : 'error',
|
||||
@@ -46,7 +48,9 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
),
|
||||
);
|
||||
for (final s in mailboxStats) {
|
||||
await _db.into(_db.syncLogMailboxes).insert(
|
||||
await _db
|
||||
.into(_db.syncLogMailboxes)
|
||||
.insert(
|
||||
SyncLogMailboxesCompanion.insert(
|
||||
syncLogId: logId,
|
||||
mailboxPath: s.mailboxPath,
|
||||
@@ -70,10 +74,11 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
return logsQuery.watch().asyncMap((rows) async {
|
||||
final entries = <SyncLogEntry>[];
|
||||
for (final r in rows) {
|
||||
final mailboxRows = await (_db.select(_db.syncLogMailboxes)
|
||||
..where((t) => t.syncLogId.equals(r.id))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)]))
|
||||
.get();
|
||||
final mailboxRows =
|
||||
await (_db.select(_db.syncLogMailboxes)
|
||||
..where((t) => t.syncLogId.equals(r.id))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)]))
|
||||
.get();
|
||||
entries.add(
|
||||
SyncLogEntry(
|
||||
id: r.id,
|
||||
|
||||
@@ -11,7 +11,9 @@ class UndoRepositoryImpl implements UndoRepository {
|
||||
|
||||
@override
|
||||
Future<void> saveAction(UndoAction action) async {
|
||||
await _db.into(_db.undoActions).insert(
|
||||
await _db
|
||||
.into(_db.undoActions)
|
||||
.insert(
|
||||
UndoActionsCompanion.insert(
|
||||
id: action.id,
|
||||
accountId: action.accountId,
|
||||
@@ -29,10 +31,11 @@ class UndoRepositoryImpl implements UndoRepository {
|
||||
|
||||
@override
|
||||
Future<List<UndoAction>> getHistory({int limit = 10}) async {
|
||||
final rows = await (_db.select(_db.undoActions)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
|
||||
..limit(limit))
|
||||
.get();
|
||||
final rows =
|
||||
await (_db.select(_db.undoActions)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
|
||||
..limit(limit))
|
||||
.get();
|
||||
return rows.map((row) {
|
||||
return UndoAction.fromJson(
|
||||
jsonDecode(row.dataJson) as Map<String, dynamic>,
|
||||
|
||||
@@ -11,14 +11,16 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
|
||||
@override
|
||||
Stream<pref.UserPreferences> observePreferences() {
|
||||
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
|
||||
.watchSingleOrNull()
|
||||
.map(_rowToModel);
|
||||
return (_db.select(
|
||||
_db.userPreferences,
|
||||
)..where((t) => t.id.equals(_rowId))).watchSingleOrNull().map(_rowToModel);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(pref.MenuPosition position) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.userPreferences)
|
||||
.insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
menuPosition: Value(position.name),
|
||||
@@ -28,7 +30,9 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
|
||||
@override
|
||||
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.userPreferences)
|
||||
.insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
mailViewButtonPosition: Value(position.name),
|
||||
@@ -40,7 +44,9 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
Future<void> updateAfterMailViewAction(
|
||||
pref.AfterMailViewAction action,
|
||||
) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
await _db
|
||||
.into(_db.userPreferences)
|
||||
.insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
afterMailViewAction: Value(action.name),
|
||||
|
||||
+48
-40
@@ -101,8 +101,9 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final searchHistoryRepositoryProvider =
|
||||
Provider<SearchHistoryRepository>((ref) {
|
||||
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
||||
ref,
|
||||
) {
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
@@ -110,10 +111,10 @@ final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLastErrorProvider =
|
||||
StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
|
||||
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
|
||||
});
|
||||
final syncLastErrorProvider = StreamProvider.autoDispose
|
||||
.family<String?, String>((ref, accountId) {
|
||||
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
|
||||
});
|
||||
|
||||
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
|
||||
final runner = ReliabilityRunner(
|
||||
@@ -126,17 +127,18 @@ final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
|
||||
return runner;
|
||||
});
|
||||
|
||||
final syncHealthProvider =
|
||||
StreamProvider.autoDispose.family<SyncHealthRow?, String>((ref, accountId) {
|
||||
final db = ref.watch(dbProvider);
|
||||
return (db.select(
|
||||
db.syncHealth,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
.watchSingleOrNull();
|
||||
});
|
||||
final syncHealthProvider = StreamProvider.autoDispose
|
||||
.family<SyncHealthRow?, String>((ref, accountId) {
|
||||
final db = ref.watch(dbProvider);
|
||||
return (db.select(
|
||||
db.syncHealth,
|
||||
)..where((t) => t.accountId.equals(accountId))).watchSingleOrNull();
|
||||
});
|
||||
|
||||
final isSyncingProvider =
|
||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||
ref,
|
||||
accountId,
|
||||
) {
|
||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||
});
|
||||
|
||||
@@ -185,15 +187,16 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||
});
|
||||
|
||||
final undoServiceProvider =
|
||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
||||
UndoService.new,
|
||||
);
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<EmailDetailNotifier, (Email?, EmailBody), String>(
|
||||
EmailDetailNotifier.new,
|
||||
);
|
||||
EmailDetailNotifier.new,
|
||||
);
|
||||
|
||||
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
EmailDetailNotifier(this._emailId);
|
||||
@@ -211,33 +214,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
}
|
||||
}
|
||||
|
||||
final accountByIdProvider =
|
||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||
(accounts) => accounts.cast<model.Account?>().firstWhere(
|
||||
final accountByIdProvider = StreamProvider.autoDispose
|
||||
.family<model.Account?, String>((ref, accountId) {
|
||||
return ref
|
||||
.watch(accountRepositoryProvider)
|
||||
.observeAccounts()
|
||||
.map(
|
||||
(accounts) => accounts.cast<model.Account?>().firstWhere(
|
||||
(a) => a?.id == accountId,
|
||||
orElse: () => null,
|
||||
),
|
||||
);
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
final accountConnectionStatusProvider =
|
||||
FutureProvider.autoDispose.family<void, String>((ref, accountId) async {
|
||||
final repo = ref.read(accountRepositoryProvider);
|
||||
final account = await repo.getAccount(accountId);
|
||||
if (account == null) throw Exception('Account not found');
|
||||
final password = await repo.getPassword(accountId);
|
||||
await ref
|
||||
.read(connectionTestServiceProvider)
|
||||
.testConnection(account, password);
|
||||
});
|
||||
final accountConnectionStatusProvider = FutureProvider.autoDispose
|
||||
.family<void, String>((ref, accountId) async {
|
||||
final repo = ref.read(accountRepositoryProvider);
|
||||
final account = await repo.getAccount(accountId);
|
||||
if (account == null) throw Exception('Account not found');
|
||||
final password = await repo.getPassword(accountId);
|
||||
await ref
|
||||
.read(connectionTestServiceProvider)
|
||||
.testConnection(account, password);
|
||||
});
|
||||
|
||||
final userPreferencesRepositoryProvider =
|
||||
Provider<UserPreferencesRepository>((ref) {
|
||||
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
||||
ref,
|
||||
) {
|
||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final userPreferencesProvider =
|
||||
StreamProvider.autoDispose<UserPreferences>((ref) {
|
||||
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||
ref,
|
||||
) {
|
||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||
});
|
||||
|
||||
+3
-3
@@ -20,9 +20,9 @@ void main({List<Override> overrides = const []}) async {
|
||||
|
||||
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||
ErrorWidget.builder = (details) => CrashScreen(
|
||||
exception: details.exception,
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
exception: details.exception,
|
||||
stackTrace: details.stack,
|
||||
);
|
||||
|
||||
// Catch framework-level errors (e.g. from gestures, timers).
|
||||
FlutterError.onError = (details) {
|
||||
|
||||
@@ -72,8 +72,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
|
||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||
try {
|
||||
final launched =
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -121,8 +123,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
);
|
||||
try {
|
||||
final launched =
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -149,10 +153,12 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
stream: _accountsStream,
|
||||
builder: (context, accountSnapshot) {
|
||||
final accounts = accountSnapshot.data ?? [];
|
||||
final imapCount =
|
||||
accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount =
|
||||
accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
final imapCount = accounts
|
||||
.where((a) => a.type == AccountType.imap)
|
||||
.length;
|
||||
final jmapCount = accounts
|
||||
.where((a) => a.type == AccountType.jmap)
|
||||
.length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('About')),
|
||||
@@ -176,9 +182,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
_launchUrl(context, Uri.parse(href)),
|
||||
);
|
||||
unawaited(_launchUrl(context, Uri.parse(href)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -209,28 +209,24 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
_Step.showingPubKey => _buildPubKeyView(context),
|
||||
_Step.scanning => _buildScannerView(context),
|
||||
_Step.importing => const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Importing accounts…'),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Importing accounts…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
_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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error: $_errorMessage'),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error: $_errorMessage'),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,8 +117,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
}
|
||||
|
||||
// Load all available accounts.
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final accounts = await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.observeAccounts()
|
||||
.first;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -158,10 +160,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
for (final account in selected) {
|
||||
final password = await repo.getPassword(account.id);
|
||||
payloads.add(
|
||||
AccountPayload(
|
||||
accountJson: account.toJson(),
|
||||
password: password,
|
||||
),
|
||||
AccountPayload(accountJson: account.toJson(), password: password),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,11 +197,11 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
_Step.selectAccounts => _buildSelectStep(context),
|
||||
_Step.showEncrypted => _buildEncryptedQrStep(context),
|
||||
_Step.error => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error: $_errorMessage'),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error: $_errorMessage'),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -361,9 +360,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Encrypted code copied to clipboard',
|
||||
),
|
||||
content: Text('Encrypted code copied to clipboard'),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -94,12 +94,12 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
_jmapApiUrlCtrl.text = sessionUrl;
|
||||
setState(() => _step = _Step.jmapForm);
|
||||
case ImapSmtpDiscovery(
|
||||
:final imapHost,
|
||||
:final imapPort,
|
||||
:final smtpHost,
|
||||
:final smtpPort,
|
||||
:final smtpSsl,
|
||||
):
|
||||
:final imapHost,
|
||||
:final imapPort,
|
||||
:final smtpHost,
|
||||
:final smtpPort,
|
||||
:final smtpSsl,
|
||||
):
|
||||
_imapHostCtrl.text = imapHost;
|
||||
_imapPortCtrl.text = imapPort.toString();
|
||||
_smtpHostCtrl.text = smtpHost;
|
||||
@@ -116,13 +116,13 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
}
|
||||
|
||||
Account _buildJmapAccount() => Account(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
displayName: _displayNameCtrl.text.trim(),
|
||||
email: _emailCtrl.text.trim(),
|
||||
username: _usernameCtrl.text.trim(),
|
||||
type: AccountType.jmap,
|
||||
jmapUrl: _jmapApiUrlCtrl.text.trim(),
|
||||
);
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
displayName: _displayNameCtrl.text.trim(),
|
||||
email: _emailCtrl.text.trim(),
|
||||
username: _usernameCtrl.text.trim(),
|
||||
type: AccountType.jmap,
|
||||
jmapUrl: _jmapApiUrlCtrl.text.trim(),
|
||||
);
|
||||
|
||||
Account _buildImapAccount() {
|
||||
final imapHost = _imapHostCtrl.text.trim();
|
||||
@@ -494,7 +494,8 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: validator ??
|
||||
validator:
|
||||
validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
|
||||
@@ -51,38 +51,37 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _emails!.isEmpty
|
||||
? const Center(child: Text('No emails'))
|
||||
: ListView.builder(
|
||||
itemCount: _emails!.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = _emails![i];
|
||||
final sender = e.from.isNotEmpty
|
||||
? (e.from.first.name ?? e.from.first.email)
|
||||
: '(unknown)';
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color:
|
||||
e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
title: Text(sender),
|
||||
subtitle: Text(
|
||||
e.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
e.mailboxPath,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
onTap: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
? const Center(child: Text('No emails'))
|
||||
: ListView.builder(
|
||||
itemCount: _emails!.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = _emails![i];
|
||||
final sender = e.from.isNotEmpty
|
||||
? (e.from.first.name ?? e.from.first.email)
|
||||
: '(unknown)';
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
title: Text(sender),
|
||||
subtitle: Text(
|
||||
e.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
e.mailboxPath,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
onTap: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
future: DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -70,7 +70,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
unawaited(_loadAccounts());
|
||||
// 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).
|
||||
final hasPrefill = widget.prefillTo != null ||
|
||||
final hasPrefill =
|
||||
widget.prefillTo != null ||
|
||||
widget.prefillSubject != null ||
|
||||
widget.prefillBody != null;
|
||||
if (!hasPrefill) unawaited(_restoreDraft());
|
||||
@@ -81,8 +82,10 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadAccounts() async {
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final accounts = await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.observeAccounts()
|
||||
.first;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_accounts = accounts;
|
||||
@@ -194,9 +197,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
await OpenFilex.open(path);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to open file: $e'),
|
||||
@@ -213,9 +214,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
|
||||
Future<void> _send() async {
|
||||
if (_accountId == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Select an account first'),
|
||||
@@ -225,8 +224,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
}
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
final account =
|
||||
(await ref.read(accountRepositoryProvider).getAccount(_accountId!))!;
|
||||
final account = (await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.getAccount(_accountId!))!;
|
||||
final draft = EmailDraft(
|
||||
from: EmailAddress(name: account.displayName, email: account.email),
|
||||
to: _to.text
|
||||
@@ -255,9 +255,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (mounted) context.pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Send failed: $e'),
|
||||
@@ -401,8 +399,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
displayStringForOption: (option) {
|
||||
final text = ctrl.text;
|
||||
final lastComma = text.lastIndexOf(',');
|
||||
final prefix =
|
||||
lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : '';
|
||||
final prefix = lastComma >= 0
|
||||
? '${text.substring(0, lastComma + 1)} '
|
||||
: '';
|
||||
return '$prefix${option.email}, ';
|
||||
},
|
||||
optionsBuilder: (value) async {
|
||||
|
||||
@@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget {
|
||||
builder: (context, snapshot) => Text(
|
||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -117,7 +117,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
int.tryParse(_sievePortCtrl.text) ?? account.manageSievePort;
|
||||
// Reset the cached probe result when any field that affects the probe
|
||||
// changed; the post-save probe will refill it.
|
||||
final sieveSettingsChanged = imapHost != account.imapHost ||
|
||||
final sieveSettingsChanged =
|
||||
imapHost != account.imapHost ||
|
||||
sieveHost != account.manageSieveHost ||
|
||||
sievePort != account.manageSievePort ||
|
||||
_sieveSsl != account.manageSieveSsl;
|
||||
@@ -138,10 +139,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
manageSieveHost: sieveHost,
|
||||
manageSievePort: sievePort,
|
||||
manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true,
|
||||
manageSieveAvailable:
|
||||
sieveSettingsChanged ? null : account.manageSieveAvailable,
|
||||
jmapUrl:
|
||||
_jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
|
||||
manageSieveAvailable: sieveSettingsChanged
|
||||
? null
|
||||
: account.manageSieveAvailable,
|
||||
jmapUrl: _jmapUrlCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _jmapUrlCtrl.text.trim(),
|
||||
verbose: _verbose,
|
||||
);
|
||||
}
|
||||
@@ -151,8 +154,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
final password = _passwordCtrl.text.isNotEmpty
|
||||
? _passwordCtrl.text
|
||||
: await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.getPassword(widget.accountId);
|
||||
.read(accountRepositoryProvider)
|
||||
.getPassword(widget.accountId);
|
||||
setState(() {
|
||||
_tryTesting = true;
|
||||
_tryOk = null;
|
||||
@@ -392,7 +395,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: validator ??
|
||||
validator:
|
||||
validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
|
||||
@@ -54,8 +54,9 @@ Future<Mailbox?> resolveMailboxByRole(
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
for (final m
|
||||
in mailboxes.where((m) => m.path != currentMailboxPath))
|
||||
for (final m in mailboxes.where(
|
||||
(m) => m.path != currentMailboxPath,
|
||||
))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(m.name),
|
||||
|
||||
@@ -55,7 +55,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
final header = detail.value?.$1;
|
||||
final body = detail.value?.$2;
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
final isMobile =
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
return Scaffold(
|
||||
@@ -72,9 +73,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(
|
||||
_replyWithRecipientDialog(context, header, body),
|
||||
);
|
||||
unawaited(_replyWithRecipientDialog(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -95,7 +94,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
|
||||
if (header != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
@@ -126,22 +127,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(
|
||||
value: 'forward',
|
||||
child: Text('Forward'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'move',
|
||||
child: Text('Move to folder'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'snooze',
|
||||
child: Text('Snooze'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'spam',
|
||||
child: Text('Mark as spam'),
|
||||
),
|
||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
@@ -155,10 +144,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
value: 'structure',
|
||||
child: Text('Show Mail Structure'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'rfc',
|
||||
child: Text('Show Raw Email'),
|
||||
),
|
||||
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
||||
],
|
||||
onSelected: (value) async {
|
||||
if (value == 'forward' && header != null) {
|
||||
@@ -264,8 +250,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.observeThreads(header.accountId, header.mailboxPath)
|
||||
.first;
|
||||
|
||||
final currentIndex =
|
||||
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
|
||||
final currentIndex = threads.indexWhere(
|
||||
(t) => t.emailIds.contains(widget.emailId),
|
||||
);
|
||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||
return threads[currentIndex + 1].latestEmailId;
|
||||
}
|
||||
@@ -337,8 +324,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
|
||||
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
||||
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
||||
final from =
|
||||
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
||||
final from = header.from.isNotEmpty
|
||||
? header.from.first.toString()
|
||||
: '(unknown)';
|
||||
final rawText = body?.textBody;
|
||||
final text = (rawText != null && rawText.isNotEmpty)
|
||||
? rawText
|
||||
@@ -352,8 +340,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
Email header,
|
||||
EmailBody? body,
|
||||
) async {
|
||||
final account =
|
||||
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
||||
final account = await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.getAccount(header.accountId);
|
||||
final ownEmail = account?.email.toLowerCase() ?? '';
|
||||
|
||||
final seen = <String>{};
|
||||
@@ -456,7 +445,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.moveEmail(widget.emailId, mailbox.path);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
@@ -492,7 +483,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.moveEmail(widget.emailId, mailbox.path);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
@@ -520,10 +513,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
extra: {
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': quoted,
|
||||
},
|
||||
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -532,12 +522,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final mailboxes =
|
||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||
final mailboxes = await mailboxRepo
|
||||
.observeMailboxes(header.accountId)
|
||||
.first;
|
||||
|
||||
// Remove the current mailbox from the list.
|
||||
final destinations =
|
||||
mailboxes.where((m) => m.path != header.mailboxPath).toList();
|
||||
final destinations = mailboxes
|
||||
.where((m) => m.path != header.mailboxPath)
|
||||
.toList();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -567,7 +559,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
@@ -625,9 +619,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.fetchRawRfc822(widget.emailId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -647,8 +641,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
Text(
|
||||
fmtSize(raw.length),
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(ctx).colorScheme.outline,
|
||||
),
|
||||
color: Theme.of(ctx).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Flexible(
|
||||
@@ -792,9 +786,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Structure not available. Try re-syncing the email.',
|
||||
),
|
||||
content: Text('Structure not available. Try re-syncing the email.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -830,8 +822,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
child: Text(
|
||||
row.label,
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -903,14 +895,8 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||
SegmentedButton<_Placement>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _Placement.to,
|
||||
label: Text('To'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.cc,
|
||||
label: Text('Cc'),
|
||||
),
|
||||
ButtonSegment(value: _Placement.to, label: Text('To')),
|
||||
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
||||
ButtonSegment(
|
||||
value: _Placement.skip,
|
||||
label: Text('Skip'),
|
||||
|
||||
@@ -92,9 +92,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
void _clearSelection() => setState(() {
|
||||
_selectedThreadIds.clear();
|
||||
_selectedSearchIds.clear();
|
||||
});
|
||||
_selectedThreadIds.clear();
|
||||
_selectedSearchIds.clear();
|
||||
});
|
||||
|
||||
void _selectAll() {
|
||||
setState(() {
|
||||
@@ -182,8 +182,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
AsyncValue<Account?> accountAsync, {
|
||||
required bool menuAtBottom,
|
||||
}) {
|
||||
final selectionCount =
|
||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||
final selectionCount = _searching
|
||||
? _selectedSearchIds.length
|
||||
: _selectedThreadIds.length;
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
@@ -277,8 +278,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
tooltip: isSyncing
|
||||
? 'Syncing…'
|
||||
: hasError
|
||||
? 'Sync error'
|
||||
: 'Sync',
|
||||
? 'Sync error'
|
||||
: 'Sync',
|
||||
icon: isSyncing
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -286,8 +287,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: hasError
|
||||
? const Icon(Icons.sync_problem, color: Colors.red)
|
||||
: const Icon(Icons.sync),
|
||||
? const Icon(Icons.sync_problem, color: Colors.red)
|
||||
: const Icon(Icons.sync),
|
||||
onPressed: isSyncing
|
||||
? null
|
||||
: () async {
|
||||
@@ -381,11 +382,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
return MaterialBanner(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
content: Text(
|
||||
error,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
leading: Icon(
|
||||
Icons.sync_problem,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
@@ -399,9 +396,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push(
|
||||
'/accounts/${widget.accountId}/sync-log',
|
||||
),
|
||||
onPressed: () =>
|
||||
context.push('/accounts/${widget.accountId}/sync-log'),
|
||||
child: const Text('View log'),
|
||||
),
|
||||
TextButton(
|
||||
@@ -470,9 +466,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
||||
final originalEmails = (await Future.wait(
|
||||
ids.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
)).whereType<Email>().toList();
|
||||
|
||||
for (final id in ids) {
|
||||
await repo.moveEmail(id, mailbox.path);
|
||||
@@ -491,10 +485,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _batchArchive() => _batchMoveToRole(
|
||||
'archive',
|
||||
dialogTitle: 'No archive folder found',
|
||||
createFolderName: 'Archive',
|
||||
);
|
||||
'archive',
|
||||
dialogTitle: 'No archive folder found',
|
||||
createFolderName: 'Archive',
|
||||
);
|
||||
|
||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||
if (!mounted || !_searching) return;
|
||||
@@ -533,9 +527,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// This is especially important for IMAP where we hard-delete the row locally.
|
||||
final originalEmails = (await Future.wait(
|
||||
ids.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
)).whereType<Email>().toList();
|
||||
|
||||
String? lastDestPath;
|
||||
for (final id in ids) {
|
||||
@@ -574,10 +566,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
||||
'junk',
|
||||
dialogTitle: 'No spam folder found',
|
||||
createFolderName: 'Junk',
|
||||
);
|
||||
'junk',
|
||||
dialogTitle: 'No spam folder found',
|
||||
createFolderName: 'Junk',
|
||||
);
|
||||
|
||||
Future<void> _batchMove() async {
|
||||
final ids = _selectedEmailIds;
|
||||
@@ -585,8 +577,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
.read(mailboxRepositoryProvider)
|
||||
.observeMailboxes(widget.accountId)
|
||||
.first;
|
||||
final destinations =
|
||||
mailboxes.where((m) => m.path != widget.mailboxPath).toList();
|
||||
final destinations = mailboxes
|
||||
.where((m) => m.path != widget.mailboxPath)
|
||||
.toList();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -618,9 +611,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
||||
final originalEmails = (await Future.wait(
|
||||
ids.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
)).whereType<Email>().toList();
|
||||
|
||||
for (final id in ids) {
|
||||
await repo.moveEmail(id, chosen);
|
||||
@@ -651,9 +642,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Fetch full email data before snoozing so we can restore them if user clicks Undo.
|
||||
final originalEmails = (await Future.wait(
|
||||
ids.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
)).whereType<Email>().toList();
|
||||
|
||||
for (final id in ids) {
|
||||
await repo.snoozeEmail(id, until);
|
||||
@@ -694,8 +683,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
final t = threads[i];
|
||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
final senderNames = t.participants
|
||||
.map((a) => a.name ?? a.email)
|
||||
.take(3)
|
||||
.join(', ');
|
||||
|
||||
final tile = ListTile(
|
||||
leading: SizedBox(
|
||||
@@ -707,8 +698,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
)
|
||||
: Icon(
|
||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||
color:
|
||||
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
||||
color: t.hasUnread
|
||||
? Theme.of(ctx).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
@@ -768,12 +760,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
onTap: _selecting
|
||||
? () => _toggleThreadSelection(t)
|
||||
: t.messageCount > 1
|
||||
? () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
)
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
? () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
)
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
onLongPress: () => _toggleThreadSelection(t),
|
||||
);
|
||||
|
||||
@@ -781,8 +773,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// (single-email threads) or the whole thread.
|
||||
return Dismissible(
|
||||
key: ValueKey(t.threadId),
|
||||
direction:
|
||||
_selecting ? DismissDirection.none : DismissDirection.horizontal,
|
||||
direction: _selecting
|
||||
? DismissDirection.none
|
||||
: DismissDirection.horizontal,
|
||||
background: _swipeBackground(
|
||||
alignment: Alignment.centerLeft,
|
||||
color: Colors.green,
|
||||
@@ -804,9 +797,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Fetch full email data before moving/deleting.
|
||||
final originalEmails = (await Future.wait(
|
||||
t.emailIds.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
)).whereType<Email>().toList();
|
||||
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
final archive = await ref
|
||||
|
||||
@@ -10,8 +10,9 @@ import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
|
||||
final _searchHistoryProvider =
|
||||
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||
ref,
|
||||
) async {
|
||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||
});
|
||||
|
||||
@@ -83,10 +84,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
emailRepo.getEmailsByAddress(widget.accountId, query),
|
||||
).wait;
|
||||
|
||||
final matchedMailboxes = allMailboxes
|
||||
.where((m) => _hasWordPrefix(m.name, ql))
|
||||
.toList()
|
||||
..sort(compareMailboxes);
|
||||
final matchedMailboxes =
|
||||
allMailboxes.where((m) => _hasWordPrefix(m.name, ql)).toList()
|
||||
..sort(compareMailboxes);
|
||||
|
||||
// Collect unique addresses from address-search results where the
|
||||
// email or display name contains the query.
|
||||
@@ -306,8 +306,9 @@ class _FolderTile extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(accountId, style: Theme.of(context).textTheme.bodySmall),
|
||||
trailing:
|
||||
mb.unreadCount > 0 ? Badge(label: Text('${mb.unreadCount}')) : null,
|
||||
trailing: mb.unreadCount > 0
|
||||
? Badge(label: Text('${mb.unreadCount}'))
|
||||
: null,
|
||||
onTap: () => context.go(
|
||||
'/accounts/$accountId/mailboxes'
|
||||
'/${Uri.encodeComponent(mb.path)}/emails',
|
||||
|
||||
@@ -56,11 +56,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
try {
|
||||
final content = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId)
|
||||
.read(localSieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
if (mounted) {
|
||||
_contentController.text = content;
|
||||
setState(() => _loadingContent = false);
|
||||
@@ -87,14 +87,18 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
});
|
||||
try {
|
||||
if (widget.isLocal) {
|
||||
await ref.read(localSieveRepositoryProvider).saveScript(
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
} else {
|
||||
await ref.read(sieveRepositoryProvider).saveScript(
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
|
||||
@@ -46,11 +46,11 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
try {
|
||||
final scripts = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.listScripts(widget.accountId)
|
||||
.read(localSieveRepositoryProvider)
|
||||
.listScripts(widget.accountId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.listScripts(widget.accountId);
|
||||
.read(sieveRepositoryProvider)
|
||||
.listScripts(widget.accountId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scripts = scripts;
|
||||
@@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||
),
|
||||
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
||||
),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
@@ -209,10 +207,10 @@ class _SieveSourceBanner extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final text = isLocal
|
||||
? '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 '
|
||||
'(ManageSieve or JMAP). '
|
||||
'Local Filters, which run on this device, are configured separately.';
|
||||
'(ManageSieve or JMAP). '
|
||||
'Local Filters, which run on this device, are configured separately.';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
@@ -230,8 +228,8 @@ class _SieveSourceBanner extends StatelessWidget {
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -40,8 +40,8 @@ String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
||||
final statusLabel = entry.isOk
|
||||
? 'OK'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent)'
|
||||
: 'Error';
|
||||
? 'Error (permanent)'
|
||||
: 'Error';
|
||||
buf.writeln('| Status | $statusLabel |');
|
||||
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||
@@ -98,16 +98,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
.read(syncLogRepositoryProvider)
|
||||
.observeSyncLogs(widget.accountId)
|
||||
.listen((entries) {
|
||||
setState(() {
|
||||
if (_syncing &&
|
||||
_presynCount != null &&
|
||||
entries.length > _presynCount!) {
|
||||
_syncing = false;
|
||||
_presynCount = null;
|
||||
}
|
||||
_entries = entries;
|
||||
});
|
||||
});
|
||||
setState(() {
|
||||
if (_syncing &&
|
||||
_presynCount != null &&
|
||||
entries.length > _presynCount!) {
|
||||
_syncing = false;
|
||||
_presynCount = null;
|
||||
}
|
||||
_entries = entries;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -125,8 +125,10 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
}
|
||||
|
||||
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final accounts = await ref
|
||||
.read(accountRepositoryProvider)
|
||||
.observeAccounts()
|
||||
.first;
|
||||
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
@@ -204,16 +206,17 @@ class _SyncLogTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final durationLabel = _fmtDuration(entry.duration);
|
||||
final proto =
|
||||
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
||||
final proto = entry.protocol.isEmpty
|
||||
? ''
|
||||
: ' · ${entry.protocol.toUpperCase()}';
|
||||
final theme = Theme.of(context);
|
||||
final errorColor = theme.colorScheme.error;
|
||||
|
||||
final subtitleText = entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent) · took $durationLabel'
|
||||
: 'Error · took $durationLabel';
|
||||
? 'Error (permanent) · took $durationLabel'
|
||||
: 'Error · took $durationLabel';
|
||||
|
||||
return ExpansionTile(
|
||||
leading: Icon(
|
||||
@@ -338,18 +341,18 @@ class _SyncLogTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _row(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value, style: const TextStyle(fontSize: 12))),
|
||||
],
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
Expanded(child: Text(value, style: const TextStyle(fontSize: 12))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,8 +101,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bodyFuture =
|
||||
ref.read(emailRepositoryProvider).getEmailBody(widget.email.id);
|
||||
_bodyFuture = ref
|
||||
.read(emailRepositoryProvider)
|
||||
.getEmailBody(widget.email.id);
|
||||
_expanded = widget.isLatest;
|
||||
if (widget.email.isSeen == false) {
|
||||
unawaited(
|
||||
@@ -229,8 +230,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
|
||||
void _reply(BuildContext context, EmailBody body, {required bool replyAll}) {
|
||||
final to =
|
||||
widget.email.from.isNotEmpty ? widget.email.from.first.email : '';
|
||||
final to = widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.email
|
||||
: '';
|
||||
final subject = (widget.email.subject?.startsWith('Re:') ?? false)
|
||||
? widget.email.subject!
|
||||
: 'Re: ${widget.email.subject ?? ''}';
|
||||
@@ -290,7 +292,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
if (!mounted) return;
|
||||
if (original != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: widget.email.accountId,
|
||||
|
||||
@@ -25,7 +25,7 @@ class UndoLogScreen extends ConsumerWidget {
|
||||
onPressed: history.isEmpty
|
||||
? 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
|
||||
? Icons.delete_outline
|
||||
: (action.type == UndoType.snooze
|
||||
? Icons.access_time
|
||||
: Icons.move_to_inbox),
|
||||
? Icons.access_time
|
||||
: Icons.move_to_inbox),
|
||||
color: action.type == UndoType.delete
|
||||
? Colors.redAccent
|
||||
: (action.type == UndoType.snooze
|
||||
? Colors.orangeAccent
|
||||
: Colors.blueAccent),
|
||||
? Colors.orangeAccent
|
||||
: Colors.blueAccent),
|
||||
),
|
||||
title: Text('$subject$extraCount'),
|
||||
subtitle: Column(
|
||||
@@ -84,9 +84,7 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
|
||||
@@ -90,9 +90,7 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
),
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Top'),
|
||||
subtitle: Text(
|
||||
'Show the back button in the top bar.',
|
||||
),
|
||||
subtitle: Text('Show the back button in the top bar.'),
|
||||
value: MenuPosition.top,
|
||||
),
|
||||
],
|
||||
@@ -122,16 +120,12 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
children: [
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Next message (default)'),
|
||||
subtitle: Text(
|
||||
'Show the next message in the mailbox.',
|
||||
),
|
||||
subtitle: Text('Show the next message in the mailbox.'),
|
||||
value: AfterMailViewAction.nextMessage,
|
||||
),
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Return to mailbox'),
|
||||
subtitle: Text(
|
||||
'Return to the message list.',
|
||||
),
|
||||
subtitle: Text('Return to the message list.'),
|
||||
value: AfterMailViewAction.showMailbox,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -26,14 +26,16 @@ String buildAboutMarkdown({
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final textScale =
|
||||
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
||||
final textScale = MediaQuery.of(
|
||||
context,
|
||||
).textScaler.scale(1.0).toStringAsFixed(1);
|
||||
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
: '';
|
||||
final deviceModelLine =
|
||||
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
||||
final deviceModelLine = deviceModel != null
|
||||
? '| Device Model | $deviceModel |\n'
|
||||
: '';
|
||||
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
|
||||
@@ -37,15 +37,17 @@ class EmailTile extends StatelessWidget {
|
||||
final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : '';
|
||||
|
||||
return ListTile(
|
||||
leading: leading ??
|
||||
leading:
|
||||
leading ??
|
||||
Icon(
|
||||
email.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style:
|
||||
email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: email.isSeen
|
||||
? null
|
||||
: const TextStyle(fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
|
||||
@@ -43,11 +43,9 @@ class FolderDrawer extends ConsumerWidget {
|
||||
Text(
|
||||
account?.displayName ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
account?.email ?? '',
|
||||
|
||||
@@ -16,7 +16,8 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||
final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
|
||||
// script-src 'none' blocks page scripts; JS mode stays unrestricted so the
|
||||
// controller can call runJavaScriptReturningResult for height measurement.
|
||||
const cspBase = "default-src 'none'; "
|
||||
const cspBase =
|
||||
"default-src 'none'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"script-src 'none'; "
|
||||
"object-src 'none'; "
|
||||
@@ -106,9 +107,9 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
}
|
||||
|
||||
String _buildHtml() => buildEmailHtml(
|
||||
widget.htmlBody,
|
||||
loadRemoteImages: widget.loadRemoteImages,
|
||||
);
|
||||
widget.htmlBody,
|
||||
loadRemoteImages: widget.loadRemoteImages,
|
||||
);
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
try {
|
||||
@@ -140,13 +141,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
final host = uri.host;
|
||||
final parts = host.split('.');
|
||||
// Bold the registered domain (last two DNS labels) to aid phishing detection.
|
||||
final boldStart = (parts.length >= 2
|
||||
? host.length -
|
||||
parts.last.length -
|
||||
1 -
|
||||
parts[parts.length - 2].length
|
||||
: 0)
|
||||
.clamp(0, host.length);
|
||||
final boldStart =
|
||||
(parts.length >= 2
|
||||
? host.length -
|
||||
parts.last.length -
|
||||
1 -
|
||||
parts[parts.length - 2].length
|
||||
: 0)
|
||||
.clamp(0, host.length);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -191,12 +193,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final launched =
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open: $url')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||
# Establishes a secure tunnel to a remote Dagger Engine via SSH using SOPS secrets.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||
# 0. Check for old environment variables
|
||||
if [ -n "${DAGGER_STUNNEL_URL:-}" ] || [ -n "${DAGGER_CA_CERT:-}" ] || [ -n "${DAGGER_SSH_KEY:-}" ]; then
|
||||
echo "ERROR: Old environment variables (DAGGER_STUNNEL_URL, DAGGER_CA_CERT, or DAGGER_SSH_KEY) are present in the environment."
|
||||
echo "Only SOPS_AGE_KEY should be set in Codeberg secrets."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||
if [ "$host" == "$port" ]; then
|
||||
port="8774"
|
||||
fi
|
||||
|
||||
MAX_PROBE_ATTEMPTS=5
|
||||
PROBE_DELAY=30
|
||||
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||
echo "Found active server on $host:$port"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||
if ! timeout 30 docker info >/dev/null 2>&1; then
|
||||
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
|
||||
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
|
||||
echo "or that Docker is running locally (check: sudo systemctl start docker)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||
exit 0
|
||||
fi
|
||||
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
||||
sleep $PROBE_DELAY
|
||||
done
|
||||
|
||||
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
timeout 8 dagger version >/dev/null 2>&1; then
|
||||
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||
|
||||
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||
mkdir -p /tmp/dagger-tls
|
||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||
chmod 600 /tmp/dagger-tls/client.key
|
||||
|
||||
# 3. Configure and start stunnel
|
||||
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||
cat << EOF > "$STUNNEL_CONF"
|
||||
client = yes
|
||||
foreground = yes
|
||||
pid = /tmp/stunnel.pid
|
||||
debug = warning
|
||||
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||
socket = r:SO_KEEPALIVE=1
|
||||
socket = r:TCP_KEEPIDLE=10
|
||||
socket = r:TCP_KEEPINTVL=5
|
||||
socket = r:TCP_KEEPCNT=3
|
||||
|
||||
[dagger]
|
||||
accept = 127.0.0.1:1774
|
||||
connect = $host:$port
|
||||
CAfile = /tmp/dagger-tls/ca.crt
|
||||
cert = /tmp/dagger-tls/client.crt
|
||||
key = /tmp/dagger-tls/client.key
|
||||
verifyChain = yes
|
||||
EOF
|
||||
|
||||
# Start stunnel in the background
|
||||
stunnel "$STUNNEL_CONF" &
|
||||
TUNNEL_PID=$!
|
||||
|
||||
# Give it a moment to establish
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||
echo "Error: stunnel failed to start"
|
||||
if [ -z "${SOPS_AGE_KEY:-}" ]; then
|
||||
echo "Error: SOPS_AGE_KEY must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Decrypt secrets using SOPS
|
||||
# We assume sops is available in the nix environment
|
||||
echo "Decrypting secrets with SOPS..."
|
||||
# Exporting for SOPS
|
||||
export SOPS_AGE_KEY="$SOPS_AGE_KEY"
|
||||
|
||||
# Create a temporary file to store decrypted secrets
|
||||
SECRETS_JSON=$(mktemp)
|
||||
trap "rm -f $SECRETS_JSON" EXIT
|
||||
|
||||
# Decrypt the SOPS file (must be in the repo root)
|
||||
sops --decrypt secrets.enc.yaml > "$SECRETS_JSON"
|
||||
|
||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||
|
||||
if [ "$DAGGER_SSH_KEY" == "null" ] || [ -z "$DAGGER_SSH_KEY" ]; then
|
||||
echo "Error: DAGGER_SSH_KEY not found in secrets.enc.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$DAGGER_ENGINE_HOST" == "null" ] || [ -z "$DAGGER_ENGINE_HOST" ]; then
|
||||
echo "Error: DAGGER_ENGINE_HOST not found in secrets.enc.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Setup SSH key
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
||||
chmod 600 ~/.ssh/dagger_key
|
||||
|
||||
# 3. Configure SSH for Dagger
|
||||
cat << SSHEOF > ~/.ssh/config.dagger
|
||||
Host dagger-engine
|
||||
HostName $DAGGER_ENGINE_HOST
|
||||
User dagger
|
||||
IdentityFile ~/.ssh/dagger_key
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh/dagger-%r@%h:%p
|
||||
ControlPersist 10m
|
||||
SSHEOF
|
||||
|
||||
# Append to main ssh config if not already there
|
||||
if ! grep -q "config.dagger" ~/.ssh/config 2>/dev/null; then
|
||||
echo "Include ~/.ssh/config.dagger" >> ~/.ssh/config
|
||||
fi
|
||||
|
||||
# 4. Export environment for subsequent CI steps
|
||||
export DAGGER_HOST="ssh://dagger-engine"
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||
echo "DAGGER_HOST=ssh://dagger-engine" >> "$GITHUB_ENV"
|
||||
echo "Tunnel established via SSH. Dagger is configured to use the remote engine at $DAGGER_ENGINE_HOST"
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||
echo "Dagger configured at ssh://dagger-engine"
|
||||
fi
|
||||
|
||||
# 5. Verify connection
|
||||
echo "Verifying Dagger connection..."
|
||||
if ! timeout 30 dagger query '{ version }' >/dev/null 2>&1; then
|
||||
echo "Error: Dagger engine is unreachable via SSH at $DAGGER_ENGINE_HOST"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dagger connection verified."
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
DAGGER_ENGINE_HOST: ENC[AES256_GCM,data:pMblsGAO/r4=,iv:LlCE8sIM4rFM1Ia3nBdqKCt8xI56wfiZKrNQdDY0VZU=,tag:hyDGXW6jw60x3jZXLJFa/Q==,type:str]
|
||||
DAGGER_SSH_KEY: ENC[AES256_GCM,data:fD9Wd7jgO34Bs156KF+VLZdfbkbOeyLioPNdxbAjH53UeUOd4lnxSWfDldeufHR+TYCjIka+5PiD5NNvH1cQPrycqHptewjuA2+V00RfkXPKi6+U4TkYmtRobHoc6wT+P5saClGl6QerIrBIWz+f1svZCn+4C65pQ4IpWjzM6iSHn+SSNtijUPuBXpzgiUg/i2m6KTI8QL+9MelkB4F0cRMgI9gfU4QvtI3IoKDKqWAGiHB/WyroylhzFoUnS2VkA0hu7K2PolS6ThWVIuClEItSvoUz7VrHfakjFv6oA23H5iIJwAX7LR8HRYW0qj0pbozEYgJhomQrR8fjQvOq+p2NKvgc6gBMO7hN2wdoYUSjoD/9WsAtDSICpFhtB7E7WWIaFzUTWFXOrXll3GOdfIqUouCzzEk8Y6tp3KHr69paeHcqNYsCCfa57N8osgV6MWMTNOIuijUwvQbbWN2uSfpcNXMV85MltDYd8xnVHiZCV/DNKK60bjYRcX2c+gGy6a9BmrWQp35rbwVnAaxgYvDwrCn7d6JLNSZs,iv:5cpyTi0r2UTuNaqVd351ds63rr7V4U1Y9NqqGZ2D0ro=,tag:DrRd8GxscAPdDG9T8OOuyw==,type:str]
|
||||
NETCUP_API_KEY: ENC[AES256_GCM,data:Dnwp+wSxKWCrWXrOAr0NqD5odZnitL7dUFZBpTmx/vIBv7l/63DU6HDiWgWConkYfGo=,iv:by+yyCzv/jLAm2BQZJIwe9cArms+G2AxmgzGRketCfQ=,tag:1Wj/Em39+3FeBqUjkQouDQ==,type:str]
|
||||
NETCUP_API_PASSWORD: ENC[AES256_GCM,data:GU8P9dQmambwV3gaHXeuTyS51dBWTPoyzDXQFdAGdlDEYG5iEoPs158sgTjoD3AB1iU=,iv:b3tOjaxJ/Nfn4NSXqDEwMfDwyli1T2mlQD2g1HrJQRk=,tag:o0ENCpV1IZdeve0o+WMtdA==,type:str]
|
||||
NETCUP_CUSTOMER_NUMBER: ENC[AES256_GCM,data:QIzD/sSd,iv:5sp4zhQzH5pla7svsuDC3aZdk4tLlWvQOrkOG5Zbp2A=,tag:FyIFvcKWdRGtuy+XAGBYiQ==,type:str]
|
||||
NTFY_ALERT_MESSAGE_URL: ENC[AES256_GCM,data:l80HCLWo6FMZrLtxMXAUKvxNgcmSJA+MnA==,iv:9+R1YO7JRP+q1CF/TRNwf/Riiq01QtngaZ2WAMy8FKo=,tag:5t9IbpE11SuS4ooCtYuGJg==,type:str]
|
||||
WIREGUARD_PRIVATE_KEY_P16: ENC[AES256_GCM,data:u3GNdUsUWcwkRxjrfQAkUty0P3m4axoTTmK8Hhnfy5dV7r3s/IP4mWqS25o=,iv:mHFQODMqJD/VVM0udpyyz3qEt4EZCSquqqurwhC/Hsw=,tag:L/abimAshyCm4wyG1h2Jag==,type:str]
|
||||
WIREGUARD_PRIVATE_KEY_SHAREDINBOX_DE: ENC[AES256_GCM,data:hF7MBGQwEYlhxg9PRyNaFXw3BFvR+Fg+2sL54QfEJMNDkJJBEV5uhY0fyKA=,iv:SI6l2+l/gZAwu1CD4zf4mFtg3cPvMYGz1I8whiJz/+Q=,tag:C92QqcS6dRJQzjOY5S+08A==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1r0k34dkgzppaew7etm3ka7p0dgxcd365gxe66kuuqsnw6hqax9qswda0sh
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1c1o3dzRzYndUVUplSTVB
|
||||
MjFsZ0Z4MmpBaXZxTys5SEFKa2VjeUJNVVZZCjI2b3MrSWg5MEtVN3ZLZ2FDZHNu
|
||||
OTM0QXBlUlRJcEdYM2hvWnhGL2JxUVkKLS0tIFB4a1dQNGtoRnFXdUVRSmpneDl3
|
||||
NVF4N1dlaEtMQmZZSlFmamRMWUdsem8K38dzAcQNcZnOZztJQ/fHlXTbkG09GF71
|
||||
V0njc2VB7Way3NuYjgXdHhYESiX92W6NMUaK0zzED5Q7jVm4D14AHg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-06-02T09:02:11Z"
|
||||
mac: ENC[AES256_GCM,data:8TduuqQ9DeE9b93RQxZsgnv7QOWUn6JD5kAMPWLaSPyqBYhq7qAhUnCa3xds/BybcZSN1uDERwebg0YLLQR8S/QTieAusRU7GZX0Bpb8/lVfADEniyXBpM5063cq7fGWT0cM/Wb+DzBa/koLOv+7OMUU2s4chd+YJgY7ByciiZQ=,iv:SHOJ4IJVwiY4kjIE1KH8uuinJYfXo7SJK4sQHcJzx5M=,tag:0mPIpu7GXOjv5Ews3YdQvQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.12.2
|
||||
@@ -16,91 +16,94 @@ Future<imap.ImapClient> _fakeImapConnect(
|
||||
Account account,
|
||||
String username,
|
||||
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() {
|
||||
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
test(
|
||||
'AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
imapConnect: _fakeImapConnect,
|
||||
);
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
imapConnect: _fakeImapConnect,
|
||||
);
|
||||
|
||||
final a1 = _account('1');
|
||||
final a2 = _account('2');
|
||||
final a1 = _account('1');
|
||||
final a2 = _account('2');
|
||||
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
|
||||
// Allow some time for listeners to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// Allow some time for listeners to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
manager.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
test(
|
||||
'AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
|
||||
final a1 = _jmapAccount('1');
|
||||
final a2 = _jmapAccount('2');
|
||||
final a1 = _jmapAccount('1');
|
||||
final a2 = _jmapAccount('2');
|
||||
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
manager.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Account _account(String id) => Account(
|
||||
id: id,
|
||||
displayName: 'Account $id',
|
||||
email: '$id@example.com',
|
||||
imapHost: 'localhost',
|
||||
imapPort: 143,
|
||||
imapSsl: false,
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
id: id,
|
||||
displayName: 'Account $id',
|
||||
email: '$id@example.com',
|
||||
imapHost: 'localhost',
|
||||
imapPort: 143,
|
||||
imapSsl: false,
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
Account _jmapAccount(String id) => Account(
|
||||
id: id,
|
||||
displayName: 'Account $id',
|
||||
email: '$id@example.com',
|
||||
type: AccountType.jmap,
|
||||
jmapUrl: 'http://localhost:8080/.well-known/jmap',
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
id: id,
|
||||
displayName: 'Account $id',
|
||||
email: '$id@example.com',
|
||||
type: AccountType.jmap,
|
||||
jmapUrl: 'http://localhost:8080/.well-known/jmap',
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
class _FakeAccounts implements AccountRepository {
|
||||
_FakeAccounts(this.password);
|
||||
@@ -129,16 +132,16 @@ class _FakeAccounts implements AccountRepository {
|
||||
class _FakeMailboxes implements MailboxRepository {
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
|
||||
Mailbox(
|
||||
id: '$accountId:INBOX',
|
||||
accountId: accountId ?? '',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
Mailbox(
|
||||
id: '$accountId:INBOX',
|
||||
accountId: accountId ?? '',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
@@ -155,27 +158,22 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeEmails implements EmailRepository {
|
||||
final syncCounts = <String, int>{};
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
@@ -183,8 +181,7 @@ class _FakeEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
}) => Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
@@ -228,8 +225,7 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async =>
|
||||
null;
|
||||
) async => null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@@ -247,8 +243,7 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
) async => '/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => '';
|
||||
@@ -267,8 +262,7 @@ class _FakeEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async =>
|
||||
[];
|
||||
}) async => [];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
@@ -278,8 +272,7 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
) async => ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
|
||||
@@ -246,8 +246,9 @@ void main() {
|
||||
);
|
||||
|
||||
// Alice and bob each received at least msgCount messages.
|
||||
final aliceEmails =
|
||||
allEmails.where((e) => e.accountId == 'alice').toList();
|
||||
final aliceEmails = allEmails
|
||||
.where((e) => e.accountId == 'alice')
|
||||
.toList();
|
||||
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
|
||||
expect(
|
||||
aliceEmails.length,
|
||||
|
||||
@@ -138,7 +138,7 @@ void main() {
|
||||
}
|
||||
|
||||
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
|
||||
makeRepo() {
|
||||
makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final storage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, storage);
|
||||
@@ -346,7 +346,9 @@ void main() {
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a legacy row with no cachedAt.
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('stale text'),
|
||||
@@ -372,7 +374,9 @@ void main() {
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a row cached 8 days ago.
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('old text'),
|
||||
@@ -566,59 +570,61 @@ void main() {
|
||||
expect(pending.first.changeType, 'delete');
|
||||
});
|
||||
|
||||
test('downloadAttachment fetches binary attachment bytes from IMAP',
|
||||
() async {
|
||||
final attachmentBytes = Uint8List.fromList(
|
||||
List.generate(32, (i) => i + 1),
|
||||
);
|
||||
const attachmentName = 'hello.bin';
|
||||
const attachmentMime = 'application/octet-stream';
|
||||
|
||||
// Build a multipart email with a binary attachment and append it.
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final builder = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||
..text = 'See attachment.';
|
||||
builder.addBinary(
|
||||
attachmentBytes,
|
||||
MediaType.fromText(attachmentMime),
|
||||
filename: attachmentName,
|
||||
test(
|
||||
'downloadAttachment fetches binary attachment bytes from IMAP',
|
||||
() async {
|
||||
final attachmentBytes = Uint8List.fromList(
|
||||
List.generate(32, (i) => i + 1),
|
||||
);
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
const attachmentName = 'hello.bin';
|
||||
const attachmentMime = 'application/octet-stream';
|
||||
|
||||
// Build a multipart email with a binary attachment and append it.
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
try {
|
||||
final builder = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||
..text = 'See attachment.';
|
||||
builder.addBinary(
|
||||
attachmentBytes,
|
||||
MediaType.fromText(attachmentMime),
|
||||
filename: attachmentName,
|
||||
);
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.hasAttachment, isTrue);
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.hasAttachment, isTrue);
|
||||
|
||||
final body = await r.emails.getEmailBody(emails.first.id);
|
||||
expect(body.attachments, hasLength(1));
|
||||
expect(body.attachments.first.filename, attachmentName);
|
||||
expect(body.attachments.first.contentType, attachmentMime);
|
||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||
final body = await r.emails.getEmailBody(emails.first.id);
|
||||
expect(body.attachments, hasLength(1));
|
||||
expect(body.attachments.first.filename, attachmentName);
|
||||
expect(body.attachments.first.contentType, attachmentMime);
|
||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||
|
||||
final path = await r.emails.downloadAttachment(
|
||||
emails.first.id,
|
||||
body.attachments.first,
|
||||
);
|
||||
final downloaded = await File(path).readAsBytes();
|
||||
expect(downloaded, equals(attachmentBytes));
|
||||
});
|
||||
final path = await r.emails.downloadAttachment(
|
||||
emails.first.id,
|
||||
body.attachments.first,
|
||||
);
|
||||
final downloaded = await File(path).readAsBytes();
|
||||
expect(downloaded, equals(attachmentBytes));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ void main() {
|
||||
AccountRepositoryImpl accounts,
|
||||
EmailRepositoryImpl emails,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
}) makeRepo() {
|
||||
})
|
||||
makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final emails = EmailRepositoryImpl(
|
||||
@@ -127,12 +128,13 @@ void main() {
|
||||
) async {
|
||||
await accounts.addAccount(account, userPass);
|
||||
await mailboxes.syncMailboxes('test-jmap');
|
||||
final row = await (db.select(db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final row =
|
||||
await (db.select(db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw StateError('INBOX not found after syncMailboxes');
|
||||
return row.path;
|
||||
}
|
||||
@@ -270,18 +272,21 @@ void main() {
|
||||
);
|
||||
|
||||
// A sent copy should appear in the Sent mailbox.
|
||||
final sentRow = await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final sentRow =
|
||||
await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final sentId = sentRow?.path;
|
||||
|
||||
if (sentId != null) {
|
||||
await r.emails.syncEmails('test-jmap', sentId);
|
||||
final sentEmails =
|
||||
await r.emails.observeEmails('test-jmap', sentId).first;
|
||||
final sentEmails = await r.emails
|
||||
.observeEmails('test-jmap', sentId)
|
||||
.first;
|
||||
expect(sentEmails.any((e) => e.subject == subject), isTrue);
|
||||
} else {
|
||||
// If no Sent mailbox exists, just verify sendEmail didn't throw.
|
||||
@@ -348,12 +353,13 @@ void main() {
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
// Find a destination mailbox (Trash).
|
||||
final trashRow = await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('trash'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final trashRow =
|
||||
await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('trash'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (trashRow == null) {
|
||||
markTestSkipped('No trash mailbox found on this Stalwart instance');
|
||||
return;
|
||||
|
||||
@@ -76,7 +76,8 @@ void main() {
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
}) makeRepo() {
|
||||
})
|
||||
makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
|
||||
@@ -107,7 +107,9 @@ void main() {
|
||||
'verifySyncReliability identifies extra local emails (missing on server)',
|
||||
() async {
|
||||
// 1. Manually insert a row into local DB that doesn't exist on server
|
||||
await db.into(db.emails).insert(
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'test:999',
|
||||
accountId: 'test',
|
||||
|
||||
@@ -73,13 +73,15 @@ abstract class AccountRepositoryContract {
|
||||
expect(await repo.getPassword(_a.id), 'new');
|
||||
});
|
||||
|
||||
test('removeAccount makes account disappear from observeAccounts',
|
||||
() async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
await repo.removeAccount(_a.id);
|
||||
expect(await repo.observeAccounts().first, isEmpty);
|
||||
});
|
||||
test(
|
||||
'removeAccount makes account disappear from observeAccounts',
|
||||
() async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
await repo.removeAccount(_a.id);
|
||||
expect(await repo.observeAccounts().first, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('getAccount returns null after removeAccount', () async {
|
||||
final repo = makeRepo();
|
||||
|
||||
@@ -37,52 +37,48 @@ void main() {
|
||||
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
||||
// loop must stop permanently instead of retrying indefinitely with backoff.
|
||||
test(
|
||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||
() async {
|
||||
final syncLog = FakeSyncLogRepository();
|
||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||
() async {
|
||||
final syncLog = FakeSyncLogRepository();
|
||||
|
||||
final m = AccountSyncManager(
|
||||
_AccountRepositoryWithMissingPlugin(),
|
||||
FakeMailboxRepositoryWithInbox(),
|
||||
FakeEmailRepository(),
|
||||
syncLog: syncLog,
|
||||
);
|
||||
final m = AccountSyncManager(
|
||||
_AccountRepositoryWithMissingPlugin(),
|
||||
FakeMailboxRepositoryWithInbox(),
|
||||
FakeEmailRepository(),
|
||||
syncLog: syncLog,
|
||||
);
|
||||
|
||||
m.start();
|
||||
m.start();
|
||||
|
||||
// Allow the first sync cycle to run and fail.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// Allow the first sync cycle to run and fail.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
expect(syncLog.logs.first.success, isFalse);
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
expect(syncLog.logs.first.success, isFalse);
|
||||
|
||||
// Kicking the loop should have no effect once it has stopped permanently.
|
||||
m.syncNow('1');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// Kicking the loop should have no effect once it has stopped permanently.
|
||||
m.syncNow('1');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Before the fix: kick triggers a retry → 2 log entries.
|
||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
// Before the fix: kick triggers a retry → 2 log entries.
|
||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
|
||||
m.dispose();
|
||||
});
|
||||
m.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class FakeEmailRepository implements EmailRepository {
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
}) => Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -117,8 +113,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async =>
|
||||
null;
|
||||
) async => null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@@ -143,8 +138,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async =>
|
||||
[];
|
||||
}) async => [];
|
||||
@override
|
||||
Stream<void> watchJmapPush(String a, String p) => const Stream.empty();
|
||||
@override
|
||||
@@ -159,8 +153,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
) async => ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
@@ -208,16 +201,16 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
||||
class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
|
||||
const Mailbox(
|
||||
id: '1:INBOX',
|
||||
accountId: '1',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
const Mailbox(
|
||||
id: '1:INBOX',
|
||||
accountId: '1',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
@override
|
||||
Future<int> syncMailboxes(String id) async => 1;
|
||||
@override
|
||||
@@ -229,16 +222,15 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||
@@ -256,11 +248,11 @@ class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||
|
||||
@override
|
||||
Future<String> getPassword(String accountId) => Future.error(
|
||||
MissingPluginException(
|
||||
'No implementation found for method read on channel '
|
||||
'plugins.it.nomads.com/flutter_secure_storage',
|
||||
),
|
||||
);
|
||||
MissingPluginException(
|
||||
'No implementation found for method read on channel '
|
||||
'plugins.it.nomads.com/flutter_secure_storage',
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> addAccount(Account account, String password) async {}
|
||||
|
||||
@@ -40,7 +40,9 @@ Future<String> _insertInboxEmail(
|
||||
String from = 'sender@example.com',
|
||||
String mailboxPath = 'INBOX',
|
||||
}) async {
|
||||
await db.into(db.emails).insert(
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
@@ -57,7 +59,9 @@ Future<String> _insertInboxEmail(
|
||||
),
|
||||
);
|
||||
// Insert a thread row so _updateThread does not throw.
|
||||
await db.into(db.threads).insertOnConflictUpdate(
|
||||
await db
|
||||
.into(db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
@@ -71,7 +75,9 @@ Future<String> _insertInboxEmail(
|
||||
|
||||
/// Creates an active Sieve script for the test account.
|
||||
Future<void> _insertSieveScript(AppDatabase db, String content) async {
|
||||
await db.into(db.localSieveScripts).insert(
|
||||
await db
|
||||
.into(db.localSieveScripts)
|
||||
.insert(
|
||||
LocalSieveScriptsCompanion.insert(
|
||||
accountId: _account.id,
|
||||
name: 'test-script',
|
||||
@@ -218,7 +224,9 @@ if header :contains "subject" ["SPAM"] {
|
||||
}
|
||||
''');
|
||||
// Insert without messageId.
|
||||
await db.into(db.emails).insert(
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'sieve-acc:2',
|
||||
accountId: _account.id,
|
||||
@@ -228,7 +236,9 @@ if header :contains "subject" ["SPAM"] {
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await db.into(db.threads).insertOnConflictUpdate(
|
||||
await db
|
||||
.into(db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: 'sieve-acc:2',
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -9,12 +9,13 @@ void main() {
|
||||
// startup, throwing PlatformException(channel-error, ...).
|
||||
// registerBackgroundSync() must absorb the failure and let the app continue.
|
||||
test(
|
||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native WorkManager plugin is not
|
||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||
// MissingPluginException. The fix catches it. This test fails before the
|
||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||
await expectLater(registerBackgroundSync(), completes);
|
||||
});
|
||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native WorkManager plugin is not
|
||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||
// MissingPluginException. The fix catches it. This test fails before the
|
||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||
await expectLater(registerBackgroundSync(), completes);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ void main() {
|
||||
|
||||
test('leaves HTML unchanged when there are no inline parts', () {
|
||||
// A plain text-only message.
|
||||
const plainMime = 'MIME-Version: 1.0\r\n'
|
||||
const plainMime =
|
||||
'MIME-Version: 1.0\r\n'
|
||||
'Content-Type: text/plain\r\n'
|
||||
'\r\n'
|
||||
'Hello';
|
||||
@@ -86,8 +87,9 @@ void main() {
|
||||
final result = injectInlineImages(html, msg);
|
||||
|
||||
// Extract base64 payload from the data URI.
|
||||
final match =
|
||||
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
|
||||
final match = RegExp(
|
||||
r'data:image/png;base64,([A-Za-z0-9+/=]+)',
|
||||
).firstMatch(result);
|
||||
expect(match, isNotNull);
|
||||
final decoded = base64.decode(match!.group(1)!);
|
||||
expect(decoded.length, greaterThan(0));
|
||||
|
||||
@@ -23,7 +23,8 @@ const _jmapAccount = Account(
|
||||
jmapUrl: 'https://example.com/jmap/session',
|
||||
);
|
||||
|
||||
const _jmapSessionJson = '{'
|
||||
const _jmapSessionJson =
|
||||
'{'
|
||||
'"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}},'
|
||||
'"accounts":{},"primaryAccounts":{},"username":"alice@example.com",'
|
||||
'"apiUrl":"https://example.com/jmap/","downloadUrl":"","uploadUrl":"","state":"0"'
|
||||
@@ -116,14 +117,15 @@ void main() {
|
||||
MockClient((_) async => http.Response('', 200)),
|
||||
imapConnect: (_, __, ___) async => FakeImapClient(),
|
||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||
manageSieveConnect: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
sieveCalled = true;
|
||||
throw Exception('should not be called');
|
||||
},
|
||||
manageSieveConnect:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
sieveCalled = true;
|
||||
throw Exception('should not be called');
|
||||
},
|
||||
);
|
||||
await svc.testConnection(_imapAccount, 'pw');
|
||||
expect(sieveCalled, false);
|
||||
@@ -142,12 +144,12 @@ void main() {
|
||||
MockClient((_) async => http.Response('', 200)),
|
||||
imapConnect: (_, __, ___) async => FakeImapClient(),
|
||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||
manageSieveConnect: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async =>
|
||||
throw Exception('sieve boom'),
|
||||
manageSieveConnect:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async => throw Exception('sieve boom'),
|
||||
);
|
||||
expect(
|
||||
() => svc.testConnection(accountWithSieve, 'pw'),
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:test/test.dart';
|
||||
// Mirrors the encoding logic in EmailRepositoryImpl so we can test it
|
||||
// independently without spinning up a database.
|
||||
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) {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
|
||||
@@ -34,7 +34,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('cancelPendingChange removes an unattempted change', () async {
|
||||
await db.into(db.pendingChanges).insert(
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -53,7 +55,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('cancelPendingChange does not remove attempted changes', () async {
|
||||
await db.into(db.pendingChanges).insert(
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -74,7 +78,9 @@ void main() {
|
||||
|
||||
test('cancelPendingChange only removes the latest matching change', () async {
|
||||
final now = DateTime.now();
|
||||
await db.into(db.pendingChanges).insert(
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -84,7 +90,9 @@ void main() {
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
await db.into(db.pendingChanges).insert(
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
|
||||
@@ -44,10 +44,7 @@ abstract class EmailRepositoryContract {
|
||||
void run() {
|
||||
test('observeEmails starts empty', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(
|
||||
await repo.observeEmails(_account.id, 'INBOX').first,
|
||||
isEmpty,
|
||||
);
|
||||
expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty);
|
||||
});
|
||||
|
||||
test('observeEmails emits inserted email', () async {
|
||||
@@ -61,10 +58,7 @@ abstract class EmailRepositoryContract {
|
||||
test('observeEmails only returns emails for the given mailbox', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||
expect(
|
||||
await repo.observeEmails(_account.id, 'Sent').first,
|
||||
isEmpty,
|
||||
);
|
||||
expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty);
|
||||
});
|
||||
|
||||
test('observeEmails orders by receivedAt descending', () async {
|
||||
@@ -116,11 +110,7 @@ abstract class EmailRepositoryContract {
|
||||
|
||||
test('setFlag flagged updates isFlagged', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:11',
|
||||
mailboxPath: 'INBOX',
|
||||
);
|
||||
await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX');
|
||||
await repo.setFlag('er-acc:11', flagged: true);
|
||||
final email = await repo.getEmail('er-acc:11');
|
||||
expect(email!.isFlagged, isTrue);
|
||||
@@ -157,10 +147,7 @@ abstract class EmailRepositoryContract {
|
||||
|
||||
test('observeThreads starts empty', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(
|
||||
await repo.observeThreads(_account.id, 'INBOX').first,
|
||||
isEmpty,
|
||||
);
|
||||
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -199,7 +186,9 @@ class _EmailRepositoryImplContract extends EmailRepositoryContract {
|
||||
bool isFlagged = false,
|
||||
DateTime? receivedAt,
|
||||
}) async {
|
||||
await _db.into(_db.emails).insert(
|
||||
await _db
|
||||
.into(_db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,11 +24,11 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
String? movedToMailbox;
|
||||
|
||||
imap.Mailbox _fakeMailbox(String path) => imap.Mailbox(
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
pathSeparator: '/',
|
||||
flags: [],
|
||||
);
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
pathSeparator: '/',
|
||||
flags: [],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox> selectMailboxByPath(
|
||||
@@ -53,8 +53,7 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
imap.StoreAction? action,
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async =>
|
||||
imap.StoreImapResult();
|
||||
}) async => imap.StoreImapResult();
|
||||
|
||||
@override
|
||||
Future<imap.GenericImapResult> uidMove(
|
||||
@@ -72,8 +71,7 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
String? fetchContentDefinition, {
|
||||
int? changedSinceModSequence,
|
||||
Duration? responseTimeout,
|
||||
}) async =>
|
||||
const imap.FetchImapResult([], null);
|
||||
}) async => const imap.FetchImapResult([], null);
|
||||
}
|
||||
|
||||
/// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService.
|
||||
|
||||
@@ -56,7 +56,8 @@ void main() {
|
||||
});
|
||||
|
||||
test('real-world HTML email snippet', () {
|
||||
const html = '<p>Hello <b>Alice</b>,</p>'
|
||||
const html =
|
||||
'<p>Hello <b>Alice</b>,</p>'
|
||||
'<p>Please find the invoice attached.</p>'
|
||||
'<p>Best regards,<br/>Bob</p>';
|
||||
final result = htmlToPlain(html);
|
||||
|
||||
@@ -11,23 +11,23 @@ const _apiUrl = 'https://jmap.example.com/api/';
|
||||
const _accountId = 'u1';
|
||||
|
||||
Map<String, dynamic> _sessionBody({String? apiUrl, String? accountId}) => {
|
||||
'apiUrl': apiUrl ?? _apiUrl,
|
||||
'accounts': {
|
||||
accountId ?? _accountId: {
|
||||
'name': 'alice@example.com',
|
||||
'isPersonal': true,
|
||||
'isReadOnly': false,
|
||||
'accountCapabilities': {},
|
||||
},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': accountId ?? _accountId,
|
||||
'urn:ietf:params:jmap:mail': accountId ?? _accountId,
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'st1',
|
||||
};
|
||||
'apiUrl': apiUrl ?? _apiUrl,
|
||||
'accounts': {
|
||||
accountId ?? _accountId: {
|
||||
'name': 'alice@example.com',
|
||||
'isPersonal': true,
|
||||
'isReadOnly': false,
|
||||
'accountCapabilities': {},
|
||||
},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': accountId ?? _accountId,
|
||||
'urn:ietf:params:jmap:mail': accountId ?? _accountId,
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'st1',
|
||||
};
|
||||
|
||||
http.Client _sessionClient({
|
||||
int sessionStatus = 200,
|
||||
|
||||
@@ -61,10 +61,7 @@ abstract class MailboxRepositoryContract {
|
||||
|
||||
test('findMailboxByRole returns null when no match', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(
|
||||
await repo.findMailboxByRole(_account.id, 'archive'),
|
||||
isNull,
|
||||
);
|
||||
expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull);
|
||||
});
|
||||
|
||||
test('findMailboxByRole returns the matching mailbox', () async {
|
||||
@@ -114,7 +111,9 @@ class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
|
||||
int unread = 0,
|
||||
int total = 0,
|
||||
}) async {
|
||||
await _db.into(_db.mailboxes).insert(
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -66,17 +66,16 @@ http.Client _mockJmap({required List<Map<String, dynamic>> apiResponses}) {
|
||||
Map<String, dynamic> _mailboxGetResponse({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _mailboxChangesResponse({
|
||||
required String oldState,
|
||||
@@ -84,25 +83,24 @@ Map<String, dynamic> _mailboxChangesResponse({
|
||||
List<String> created = const [],
|
||||
List<String> updated = const [],
|
||||
List<String> destroyed = const [],
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/changes',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'oldState': oldState,
|
||||
'newState': newState,
|
||||
'hasMoreChanges': false,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'destroyed': destroyed,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/changes',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'oldState': oldState,
|
||||
'newState': newState,
|
||||
'hasMoreChanges': false,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'destroyed': destroyed,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
||||
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
|
||||
@@ -111,7 +109,8 @@ Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
}) _makeRepos({http.Client? httpClient}) {
|
||||
})
|
||||
_makeRepos({http.Client? httpClient}) {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
@@ -145,7 +144,9 @@ void main() {
|
||||
('INBOX', 'Inbox'),
|
||||
('Drafts', 'Drafts'),
|
||||
]) {
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:$path',
|
||||
accountId: 'acc-1',
|
||||
@@ -178,7 +179,9 @@ void main() {
|
||||
);
|
||||
await r.accounts.addAccount(other, 'pw2');
|
||||
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -186,7 +189,9 @@ void main() {
|
||||
name: 'Inbox',
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-2:INBOX',
|
||||
accountId: 'acc-2',
|
||||
@@ -205,7 +210,9 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -305,7 +312,9 @@ void main() {
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
// Pre-populate DB with existing mailboxes and state
|
||||
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -315,7 +324,9 @@ void main() {
|
||||
totalCount: const Value(10),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx2',
|
||||
accountId: 'jmap-1',
|
||||
@@ -323,7 +334,9 @@ void main() {
|
||||
name: 'Sent',
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Mailbox',
|
||||
@@ -351,7 +364,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Mailbox',
|
||||
@@ -419,7 +434,9 @@ void main() {
|
||||
test('findMailboxByRole returns matching mailbox', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx-inbox',
|
||||
accountId: 'jmap-1',
|
||||
@@ -486,8 +503,11 @@ void main() {
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
final result = await r.mailboxes
|
||||
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
|
||||
final result = await r.mailboxes.createMailboxWithRole(
|
||||
'jmap-1',
|
||||
'Archive',
|
||||
'archive',
|
||||
);
|
||||
|
||||
expect(result.name, 'Archive');
|
||||
expect(result.role, 'archive');
|
||||
@@ -498,81 +518,82 @@ void main() {
|
||||
expect(found!.name, 'Archive');
|
||||
});
|
||||
|
||||
test(
|
||||
'JMAP: throws when server returns no created ID',
|
||||
() async {
|
||||
final r = _makeRepos(
|
||||
httpClient: _mockJmap(
|
||||
apiResponses: [
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': null,
|
||||
'notCreated': {
|
||||
'new-mailbox': {'type': 'serverFail'},
|
||||
},
|
||||
test('JMAP: throws when server returns no created ID', () async {
|
||||
final r = _makeRepos(
|
||||
httpClient: _mockJmap(
|
||||
apiResponses: [
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': null,
|
||||
'notCreated': {
|
||||
'new-mailbox': {'type': 'serverFail'},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
],
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
await expectLater(
|
||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
await expectLater(
|
||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('syncMailboxes IMAP preserves manually-set role', () {
|
||||
test('existing role is kept when server returns no special-use flag',
|
||||
() async {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
// Make listMailboxes return a plain folder without \Archive.
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
test(
|
||||
'existing role is kept when server returns no special-use flag',
|
||||
() async {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
// Make listMailboxes return a plain folder without \Archive.
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
|
||||
// Override listMailboxes to return one plain folder.
|
||||
final fakeClient = _PlainArchiveImapClient();
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: (_, __, ___) async => fakeClient,
|
||||
);
|
||||
await accounts.addAccount(_account, 'pw');
|
||||
// Override listMailboxes to return one plain folder.
|
||||
final fakeClient = _PlainArchiveImapClient();
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: (_, __, ___) async => fakeClient,
|
||||
);
|
||||
await accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
path: 'Archive',
|
||||
name: 'Archive',
|
||||
role: const Value('archive'),
|
||||
),
|
||||
);
|
||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
path: 'Archive',
|
||||
name: 'Archive',
|
||||
role: const Value('archive'),
|
||||
),
|
||||
);
|
||||
|
||||
await mailboxes.syncMailboxes('acc-1');
|
||||
await mailboxes.syncMailboxes('acc-1');
|
||||
|
||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
||||
expect(
|
||||
found,
|
||||
isNotNull,
|
||||
reason: 'Manually-set role should be preserved after sync',
|
||||
);
|
||||
expect(found!.path, 'Archive');
|
||||
// Suppress unused warning on spy.
|
||||
expect(spy, isNotNull);
|
||||
});
|
||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
||||
expect(
|
||||
found,
|
||||
isNotNull,
|
||||
reason: 'Manually-set role should be preserved after sync',
|
||||
);
|
||||
expect(found!.path, 'Archive');
|
||||
// Suppress unused warning on spy.
|
||||
expect(spy, isNotNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -587,22 +608,20 @@ class _PlainArchiveImapClient extends SnoozeSpyImapClient {
|
||||
List<String>? mailboxPatterns,
|
||||
List<String>? selectionOptions,
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
}) async =>
|
||||
[
|
||||
imap.Mailbox(
|
||||
encodedName: 'Archive',
|
||||
encodedPath: 'Archive',
|
||||
pathSeparator: '/',
|
||||
flags: [], // No \Archive special-use flag
|
||||
),
|
||||
];
|
||||
}) async => [
|
||||
imap.Mailbox(
|
||||
encodedName: 'Archive',
|
||||
encodedPath: 'Archive',
|
||||
pathSeparator: '/',
|
||||
flags: [], // No \Archive special-use flag
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox> statusMailbox(
|
||||
imap.Mailbox mailbox,
|
||||
List<imap.StatusFlags> flags,
|
||||
) async =>
|
||||
mailbox;
|
||||
) async => mailbox;
|
||||
|
||||
@override
|
||||
Future<dynamic> logout() async {}
|
||||
|
||||
@@ -27,12 +27,12 @@ class _RecordingRepo implements AccountRepository {
|
||||
ManageSieveProbeService _service(_RecordingRepo repo, {required bool result}) {
|
||||
return ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async =>
|
||||
result,
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async => result,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,14 +71,15 @@ void main() {
|
||||
var probeCalled = false;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const jmap = Account(
|
||||
id: 'acc-2',
|
||||
@@ -97,14 +98,15 @@ void main() {
|
||||
var probeCalled = false;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const blank = Account(
|
||||
id: 'acc-3',
|
||||
@@ -123,16 +125,17 @@ void main() {
|
||||
bool? probedTls;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
probedPort = port;
|
||||
probedTls = useTls;
|
||||
return true;
|
||||
},
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
probedPort = port;
|
||||
probedTls = useTls;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const account = Account(
|
||||
id: 'acc-1',
|
||||
@@ -155,14 +158,15 @@ void main() {
|
||||
String? probedHost;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
return true;
|
||||
},
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await svc.probe(_imapAccount);
|
||||
expect(probedHost, 'imap.example.com');
|
||||
|
||||
@@ -162,8 +162,9 @@ void main() {
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
final triggerNames = allTriggers
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
@@ -178,17 +179,17 @@ void main() {
|
||||
|
||||
// v28: mime_tree_json column on email_bodies.
|
||||
await db
|
||||
.customSelect(
|
||||
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||
)
|
||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
||||
.get();
|
||||
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns =
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
final syncLogMailboxColumns = await _tableColumns(
|
||||
db,
|
||||
'sync_log_mailboxes',
|
||||
);
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v32: local_sieve_applied table.
|
||||
@@ -214,14 +215,14 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
||||
() async {
|
||||
final dbFile = File('test_migration_v22.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
||||
() async {
|
||||
final dbFile = File('test_migration_v22.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
|
||||
// Build a v22 database schema directly with raw SQL.
|
||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||
rawDb.execute('''
|
||||
// Build a v22 database schema directly with raw SQL.
|
||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||
rawDb.execute('''
|
||||
CREATE TABLE accounts (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
@@ -242,7 +243,7 @@ void main() {
|
||||
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE drafts (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NULL,
|
||||
@@ -254,7 +255,7 @@ void main() {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE mailboxes (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
@@ -265,7 +266,7 @@ void main() {
|
||||
role TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE emails (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
@@ -289,7 +290,7 @@ void main() {
|
||||
snoozed_from_mailbox_path TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE threads (
|
||||
account_id TEXT NOT NULL,
|
||||
mailbox_path TEXT NOT NULL,
|
||||
@@ -306,7 +307,7 @@ void main() {
|
||||
PRIMARY KEY (account_id, mailbox_path, id)
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE email_bodies (
|
||||
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
|
||||
text_body TEXT NULL,
|
||||
@@ -316,7 +317,7 @@ void main() {
|
||||
headers_json TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE sync_logs (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL,
|
||||
@@ -333,7 +334,7 @@ void main() {
|
||||
protocol_log TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE sync_log_mailboxes (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
|
||||
@@ -343,77 +344,81 @@ void main() {
|
||||
bytes_transferred INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''');
|
||||
rawDb.execute('PRAGMA user_version = 22;');
|
||||
rawDb.close();
|
||||
rawDb.execute('PRAGMA user_version = 22;');
|
||||
rawDb.close();
|
||||
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
// Trigger migration.
|
||||
await db.select(db.accounts).get();
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
// Trigger migration.
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
final emailColumns = await _tableColumns(db, 'emails');
|
||||
expect(emailColumns, contains('list_unsubscribe_header'));
|
||||
final emailColumns = await _tableColumns(db, 'emails');
|
||||
expect(emailColumns, contains('list_unsubscribe_header'));
|
||||
|
||||
final draftColumns = await _tableColumns(db, 'drafts');
|
||||
expect(draftColumns, contains('imap_server_id'));
|
||||
final draftColumns = await _tableColumns(db, 'drafts');
|
||||
expect(draftColumns, contains('imap_server_id'));
|
||||
|
||||
// v25: new indexes on mailboxes and threads.
|
||||
final allIndexes = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
.get();
|
||||
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||
expect(indexNames, contains('mailboxes_account_id'));
|
||||
expect(indexNames, contains('threads_latest_date'));
|
||||
// v25: new indexes on mailboxes and threads.
|
||||
final allIndexes = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
.get();
|
||||
final indexNames = allIndexes
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
expect(indexNames, contains('mailboxes_account_id'));
|
||||
expect(indexNames, contains('threads_latest_date'));
|
||||
|
||||
// v26: FTS5 virtual table and triggers.
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
);
|
||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||
// v26: FTS5 virtual table and triggers.
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames = allTriggers
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
);
|
||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||
|
||||
// v27: search_history_entries table.
|
||||
await db
|
||||
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||
.get();
|
||||
// v27: search_history_entries table.
|
||||
await db
|
||||
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||
.get();
|
||||
|
||||
// v28: mime_tree_json column on email_bodies.
|
||||
await db
|
||||
.customSelect(
|
||||
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||
)
|
||||
.get();
|
||||
// v28: mime_tree_json column on email_bodies.
|
||||
await db
|
||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
||||
.get();
|
||||
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns =
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns = await _tableColumns(
|
||||
db,
|
||||
'sync_log_mailboxes',
|
||||
);
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
},
|
||||
);
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
@@ -453,8 +458,10 @@ void main() {
|
||||
expect(draftColumns, contains('imap_server_id'));
|
||||
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns =
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
final syncLogMailboxColumns = await _tableColumns(
|
||||
db,
|
||||
'sync_log_mailboxes',
|
||||
);
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
|
||||
@@ -9,14 +9,15 @@ void main() {
|
||||
// absent at startup, throwing MissingPluginException (or a similar error).
|
||||
// initNotifications() must absorb the failure and let the app continue.
|
||||
test(
|
||||
'initNotifications completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native plugin is not registered, so
|
||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||
// false. This test fails before the fix (exception propagates) and passes
|
||||
// after it (exception is swallowed).
|
||||
await expectLater(initNotifications(), completes);
|
||||
});
|
||||
'initNotifications completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native plugin is not registered, so
|
||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||
// false. This test fails before the fix (exception propagates) and passes
|
||||
// after it (exception is swallowed).
|
||||
await expectLater(initNotifications(), completes);
|
||||
},
|
||||
);
|
||||
|
||||
test('showNewMailNotification completes without throwing', () async {
|
||||
// Platform.isAndroid is false in tests, so this returns early without
|
||||
|
||||
@@ -67,16 +67,15 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeEmails implements EmailRepository {
|
||||
@@ -100,8 +99,7 @@ class _FakeEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
}) => Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -138,8 +136,7 @@ class _FakeEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async =>
|
||||
[];
|
||||
}) async => [];
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
||||
Stream.value([]);
|
||||
|
||||
@@ -13,11 +13,11 @@ import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Account _account({String id = 'a1'}) => Account(
|
||||
id: id,
|
||||
displayName: 'Test',
|
||||
email: 'test@example.com',
|
||||
imapHost: 'localhost',
|
||||
);
|
||||
id: id,
|
||||
displayName: 'Test',
|
||||
email: 'test@example.com',
|
||||
imapHost: 'localhost',
|
||||
);
|
||||
|
||||
class _FakeAccounts implements AccountRepository {
|
||||
final List<Account> accounts;
|
||||
@@ -26,11 +26,9 @@ class _FakeAccounts implements AccountRepository {
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
||||
@override
|
||||
Future<Account?> getAccount(String id) async =>
|
||||
accounts.cast<Account?>().firstWhere(
|
||||
(a) => a?.id == id,
|
||||
orElse: () => null,
|
||||
);
|
||||
Future<Account?> getAccount(String id) async => accounts
|
||||
.cast<Account?>()
|
||||
.firstWhere((a) => a?.id == id, orElse: () => null);
|
||||
@override
|
||||
Future<void> addAccount(Account account, String password) async {}
|
||||
@override
|
||||
@@ -59,16 +57,15 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _CountingEmails implements EmailRepository {
|
||||
@@ -94,19 +91,14 @@ class _CountingEmails implements EmailRepository {
|
||||
@override
|
||||
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
}) => Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -140,8 +132,7 @@ class _CountingEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async =>
|
||||
[];
|
||||
}) async => [];
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
||||
Stream.value([]);
|
||||
@@ -159,8 +150,7 @@ class _CountingEmails implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async =>
|
||||
null;
|
||||
) async => null;
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
@override
|
||||
@@ -170,8 +160,7 @@ class _CountingEmails implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
) async => ReliabilityResult.healthy;
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
@override
|
||||
@@ -383,7 +372,7 @@ void main() {
|
||||
|
||||
class _OverrideEmails extends _CountingEmails {
|
||||
_OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync})
|
||||
: _onSync = onSync;
|
||||
: _onSync = onSync;
|
||||
|
||||
final Future<SyncEmailsResult> Function(String) _onSync;
|
||||
|
||||
|
||||
@@ -47,9 +47,7 @@ void main() {
|
||||
test('parsePublicKeyQr returns null for invalid input', () {
|
||||
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
|
||||
expect(
|
||||
ShareEncryptionService.parsePublicKeyQr(
|
||||
'sharedinbox.de:pubkey:v1:!!!',
|
||||
),
|
||||
ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -73,11 +73,7 @@ void main() {
|
||||
SieveRule(
|
||||
joinType: 'single',
|
||||
conditions: [
|
||||
HeaderCondition(
|
||||
['from', 'reply-to'],
|
||||
':is',
|
||||
['boss@work.com'],
|
||||
),
|
||||
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
|
||||
],
|
||||
actions: [
|
||||
FlagAction([r'\Important']),
|
||||
@@ -121,8 +117,10 @@ void main() {
|
||||
),
|
||||
];
|
||||
|
||||
final ctx =
|
||||
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
|
||||
final ctx = interp.execute(
|
||||
rules,
|
||||
_email(subject: 'Weekly Newsletter Issue'),
|
||||
);
|
||||
expect(ctx.targetFolders, contains('Bulk'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,8 +261,9 @@ if exists "X-Spam-Flag" {
|
||||
|
||||
group('SieveParser — rule model', () {
|
||||
test('simple if produces one rule with branchGroupId', () {
|
||||
final rules =
|
||||
parser.parse('if header :contains "Subject" "x" { discard; }');
|
||||
final rules = parser.parse(
|
||||
'if header :contains "Subject" "x" { discard; }',
|
||||
);
|
||||
expect(rules, hasLength(1));
|
||||
expect(rules.first.branchGroupId, isNotNull);
|
||||
expect(rules.first.conditions, hasLength(1));
|
||||
|
||||
@@ -11,7 +11,9 @@ void main() {
|
||||
late final db = openTestDatabase();
|
||||
|
||||
setUpAll(() async {
|
||||
await db.into(db.accounts).insert(
|
||||
await db
|
||||
.into(db.accounts)
|
||||
.insert(
|
||||
AccountsCompanion.insert(
|
||||
id: 'acc1',
|
||||
displayName: 'Test',
|
||||
@@ -120,40 +122,41 @@ void main() {
|
||||
|
||||
final rows = await (db.select(
|
||||
db.syncLogs,
|
||||
)..where((r) => r.result.equals('error')))
|
||||
.get();
|
||||
)..where((r) => r.result.equals('error'))).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.result, 'error');
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
});
|
||||
|
||||
test('stores and retrieves stackTrace and isPermanent on error entries',
|
||||
() async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 9);
|
||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||
test(
|
||||
'stores and retrieves stackTrace and isPermanent on error entries',
|
||||
() async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 9);
|
||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: fakeTrace,
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 0,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: fakeTrace,
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 0,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(entry.stackTrace, fakeTrace);
|
||||
expect(entry.isPermanent, true);
|
||||
expect(entry.errorMessage, 'MissingPluginException');
|
||||
});
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(entry.stackTrace, fakeTrace);
|
||||
expect(entry.isPermanent, true);
|
||||
expect(entry.errorMessage, 'MissingPluginException');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ void main() {
|
||||
await accounts.addAccount(account, 'password');
|
||||
|
||||
// Setup Inbox and Trash mailboxes
|
||||
await db.into(db.mailboxes).insert(
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc1:INBOX',
|
||||
accountId: 'acc1',
|
||||
@@ -56,7 +58,9 @@ void main() {
|
||||
name: 'Inbox',
|
||||
),
|
||||
);
|
||||
await db.into(db.mailboxes).insert(
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc1:Trash',
|
||||
accountId: 'acc1',
|
||||
@@ -67,7 +71,9 @@ void main() {
|
||||
);
|
||||
|
||||
// Setup an email in Inbox
|
||||
await db.into(db.emails).insert(
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc1:101',
|
||||
accountId: 'acc1',
|
||||
@@ -94,10 +100,11 @@ void main() {
|
||||
await repo.deleteEmail(emailId);
|
||||
|
||||
// Verify it moved from INBOX (locally deleted for IMAP move)
|
||||
final inInbox = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final inInbox =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(inInbox, isEmpty, reason: 'Email should be gone from Inbox');
|
||||
|
||||
// 2. Push undo action and undo
|
||||
@@ -113,10 +120,11 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
|
||||
expect(
|
||||
restored,
|
||||
@@ -141,7 +149,9 @@ void main() {
|
||||
await accounts.addAccount(jmapAccount, 'password');
|
||||
|
||||
// Setup Inbox and Trash mailboxes for JMAP
|
||||
await db.into(db.mailboxes).insert(
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap1:INBOX',
|
||||
accountId: 'jmap1',
|
||||
@@ -150,7 +160,9 @@ void main() {
|
||||
role: const Value('inbox'),
|
||||
),
|
||||
);
|
||||
await db.into(db.mailboxes).insert(
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap1:Trash',
|
||||
accountId: 'jmap1',
|
||||
@@ -161,7 +173,9 @@ void main() {
|
||||
);
|
||||
|
||||
// Setup an email in JMAP Inbox
|
||||
await db.into(db.emails).insert(
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: emailId,
|
||||
accountId: 'jmap1',
|
||||
@@ -176,10 +190,11 @@ void main() {
|
||||
await repo.deleteEmail(emailId);
|
||||
|
||||
// Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath)
|
||||
final inTrash = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('Trash')))
|
||||
.get();
|
||||
final inTrash =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('Trash')))
|
||||
.get();
|
||||
expect(inTrash, isNotEmpty, reason: 'Email should be in Trash');
|
||||
|
||||
// 2. Push undo action and undo
|
||||
@@ -194,10 +209,11 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(
|
||||
restored,
|
||||
isNotEmpty,
|
||||
@@ -234,10 +250,11 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 4. Verify local state
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(restored, isNotEmpty);
|
||||
|
||||
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
|
||||
@@ -260,8 +277,9 @@ void main() {
|
||||
expect(original!.messageId, isNull); // set a messageId so lookup works
|
||||
|
||||
// Seed a messageId so undo can find the email after UID change.
|
||||
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
|
||||
.write(const EmailsCompanion(messageId: Value('msg-101@test')));
|
||||
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write(
|
||||
const EmailsCompanion(messageId: Value('msg-101@test')),
|
||||
);
|
||||
|
||||
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
||||
|
||||
@@ -272,7 +290,9 @@ void main() {
|
||||
// 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.
|
||||
await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go();
|
||||
await db.into(db.emails).insert(
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc1:205',
|
||||
accountId: 'acc1',
|
||||
@@ -303,9 +323,9 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 4. Verify the current email row is now in INBOX.
|
||||
final inInbox = await (db.select(db.emails)
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final inInbox = await (db.select(
|
||||
db.emails,
|
||||
)..where((t) => t.mailboxPath.equals('INBOX'))).get();
|
||||
expect(
|
||||
inInbox,
|
||||
isNotEmpty,
|
||||
|
||||
@@ -122,70 +122,74 @@ void main() {
|
||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||
});
|
||||
|
||||
test('undo pushes inverse action into log when destinationMailboxPath is set',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'del1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.delete,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
destinationMailboxPath: 'Trash',
|
||||
);
|
||||
test(
|
||||
'undo pushes inverse action into log when destinationMailboxPath is set',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'del1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.delete,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
destinationMailboxPath: 'Trash',
|
||||
);
|
||||
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'del1');
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'del1');
|
||||
|
||||
// Original entry stays; inverse is added.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 2);
|
||||
expect(log[0].id, 'del1');
|
||||
final inv = log[1];
|
||||
expect(inv.id, 'del1-inv');
|
||||
expect(inv.type, UndoType.move);
|
||||
expect(inv.emailIds, ['e1']);
|
||||
expect(inv.sourceMailboxPath, 'Trash');
|
||||
expect(inv.destinationMailboxPath, 'INBOX');
|
||||
verify(
|
||||
mockUndoRepo.saveAction(
|
||||
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
// Original entry stays; inverse is added.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 2);
|
||||
expect(log[0].id, 'del1');
|
||||
final inv = log[1];
|
||||
expect(inv.id, 'del1-inv');
|
||||
expect(inv.type, UndoType.move);
|
||||
expect(inv.emailIds, ['e1']);
|
||||
expect(inv.sourceMailboxPath, 'Trash');
|
||||
expect(inv.destinationMailboxPath, 'INBOX');
|
||||
verify(
|
||||
mockUndoRepo.saveAction(
|
||||
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('undo without destinationMailboxPath does not push inverse action',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'mv1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.move,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
// no destinationMailboxPath
|
||||
);
|
||||
test(
|
||||
'undo without destinationMailboxPath does not push inverse action',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'mv1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.move,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
// no destinationMailboxPath
|
||||
);
|
||||
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'mv1');
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'mv1');
|
||||
|
||||
// Original entry stays; no inverse since no destinationMailboxPath.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 1);
|
||||
expect(log.first.id, 'mv1');
|
||||
});
|
||||
// Original entry stays; no inverse since no destinationMailboxPath.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 1);
|
||||
expect(log.first.id, 'mv1');
|
||||
},
|
||||
);
|
||||
|
||||
test('undo with actionId removes and undos specific action', () async {
|
||||
// action1 has no destination → no inverse action
|
||||
@@ -350,13 +354,9 @@ void main() {
|
||||
);
|
||||
|
||||
// Simulate slow DB load
|
||||
when(
|
||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||
).thenAnswer(
|
||||
(_) => Future.delayed(
|
||||
const Duration(milliseconds: 10),
|
||||
() => [persisted],
|
||||
),
|
||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer(
|
||||
(_) =>
|
||||
Future.delayed(const Duration(milliseconds: 10), () => [persisted]),
|
||||
);
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
|
||||
@@ -37,7 +37,8 @@ class ThrowingUrlLauncher extends Mock
|
||||
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: '
|
||||
message:
|
||||
'Unable to establish connection on channel: '
|
||||
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
||||
);
|
||||
}
|
||||
@@ -46,8 +47,9 @@ class ThrowingUrlLauncher extends Mock
|
||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
accountRepositoryProvider
|
||||
.overrideWithValue(FakeAccountRepository(accounts)),
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: AboutScreen()),
|
||||
);
|
||||
@@ -151,8 +153,10 @@ void main() {
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
@@ -173,10 +177,7 @@ void main() {
|
||||
expect(clipboardText, contains('Locale'));
|
||||
expect(clipboardText, contains('Text Scale'));
|
||||
expect(clipboardText, contains('DB Schema Version'));
|
||||
expect(
|
||||
clipboardText,
|
||||
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||
);
|
||||
expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)'));
|
||||
});
|
||||
|
||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||
|
||||
@@ -74,10 +74,7 @@ void main() {
|
||||
recipientKeyId: material.keyId,
|
||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
||||
accounts: [
|
||||
AccountPayload(
|
||||
accountJson: account.toJson(),
|
||||
password: 'secret',
|
||||
),
|
||||
AccountPayload(accountJson: account.toJson(), password: 'secret'),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -99,10 +96,7 @@ void main() {
|
||||
await tester.tap(find.text('Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Imported 1 account successfully.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Imported 1 account successfully.'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -227,54 +227,52 @@ void main() {
|
||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows discrepancy details when sync health has discrepancies',
|
||||
(tester) async {
|
||||
const summary =
|
||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: false,
|
||||
discrepancySummary: summary,
|
||||
),
|
||||
testWidgets('shows discrepancy details when sync health has discrepancies', (
|
||||
tester,
|
||||
) async {
|
||||
const summary =
|
||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: false,
|
||||
discrepancySummary: summary,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'sync health row is positioned below the account name row',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: true,
|
||||
),
|
||||
testWidgets('sync health row is positioned below the account name row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
||||
expect(healthPos, greaterThan(namePos));
|
||||
},
|
||||
);
|
||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
||||
expect(healthPos, greaterThan(namePos));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ void main() {
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
const exception = 'TestException: clipboard test';
|
||||
@@ -126,79 +128,77 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
testWidgets('CrashScreen shows git hash as clickable link above stacktrace', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
|
||||
const exception = 'TestException: git hash test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
const exception = 'TestException: git hash test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(gitLinkFinder, findsOneWidget);
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(gitLinkFinder, findsOneWidget);
|
||||
|
||||
// Link must appear above the stack trace
|
||||
final stackTraceFinder = find.text('Stack Trace:');
|
||||
expect(
|
||||
tester.getTopLeft(gitLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||
);
|
||||
// Link must appear above the stack trace
|
||||
final stackTraceFinder = find.text('Stack Trace:');
|
||||
expect(
|
||||
tester.getTopLeft(gitLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||
);
|
||||
|
||||
// Tapping the link should open the Codeberg commit URL
|
||||
await tester.tap(gitLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
// Tapping the link should open the Codeberg commit URL
|
||||
await tester.tap(gitLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows version, build mode, and platform in the UI',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const exception = 'TestException: info row test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const exception = 'TestException: info row test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Info row shows app version (from mock), build mode, and platform OS.
|
||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||
// In test builds kDebugMode is true.
|
||||
expect(find.textContaining('debug'), findsOneWidget);
|
||||
// Platform OS is always present (linux in CI, android/ios on device).
|
||||
expect(
|
||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||
findsWidgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
// Info row shows app version (from mock), build mode, and platform OS.
|
||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||
// In test builds kDebugMode is true.
|
||||
expect(find.textContaining('debug'), findsOneWidget);
|
||||
// Platform OS is always present (linux in CI, android/ios on device).
|
||||
expect(
|
||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows app version as clickable link when git hash is set',
|
||||
@@ -264,8 +264,10 @@ void main() {
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
const exception = 'TestException: version link clipboard test';
|
||||
|
||||
@@ -106,62 +106,62 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'try connection button is disabled when no password stored or entered',
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
'try connection button is disabled when no password stored or entered',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'try connection button is enabled after typing password with no stored password',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
'try connection button is enabled after typing password with no stored password',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('editPasswordField')),
|
||||
'mypassword',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('editPasswordField')),
|
||||
'mypassword',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
});
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('save button is disabled when no password stored or entered', (
|
||||
tester,
|
||||
@@ -182,8 +182,9 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Save'),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -41,23 +41,19 @@ class _FakeFile extends Fake implements File {
|
||||
FileMode mode = FileMode.write,
|
||||
Encoding encoding = utf8,
|
||||
bool flush = false,
|
||||
}) async =>
|
||||
this;
|
||||
}) async => this;
|
||||
}
|
||||
|
||||
// Shared overrides for email detail tests.
|
||||
List<Override> _overrides({required EmailBody body, Email? email}) => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emailDetail: email ?? testEmail(),
|
||||
emailBody: body,
|
||||
),
|
||||
),
|
||||
];
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('EmailDetailScreen', () {
|
||||
@@ -191,45 +187,45 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply all',
|
||||
),
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Reply on single-recipient email navigates directly to compose',
|
||||
(tester) async {
|
||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||
// only bob remains → no dialog, navigate straight to compose.
|
||||
final email = testEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: [
|
||||
..._overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets(
|
||||
'Reply on single-recipient email navigates directly to compose',
|
||||
(tester) async {
|
||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||
// only bob remains → no dialog, navigate straight to compose.
|
||||
final email = testEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: [
|
||||
..._overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No dialog shown — straight navigation to compose.
|
||||
expect(find.text('Reply All'), findsNothing);
|
||||
});
|
||||
// No dialog shown — straight navigation to compose.
|
||||
expect(find.text('Reply All'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Reply on multi-recipient email shows Reply All dialog',
|
||||
(tester) async {
|
||||
testWidgets('Reply on multi-recipient email shows Reply All dialog', (
|
||||
tester,
|
||||
) async {
|
||||
// Email with an extra Cc recipient so the dialog is triggered.
|
||||
final email = Email(
|
||||
id: 'acc-1:42',
|
||||
@@ -258,9 +254,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply',
|
||||
),
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -271,8 +265,9 @@ void main() {
|
||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam is in popup menu, not a standalone button',
|
||||
(tester) async {
|
||||
testWidgets('Mark as spam is in popup menu, not a standalone button', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -298,8 +293,9 @@ void main() {
|
||||
expect(find.text('Mark as spam'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||
(tester) async {
|
||||
testWidgets('Mark as spam shows dialog when no junk folder', (
|
||||
tester,
|
||||
) async {
|
||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||
// returns null → dialog shown.
|
||||
await tester.pumpWidget(
|
||||
@@ -334,9 +330,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Archive',
|
||||
),
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
@@ -355,17 +349,16 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Archive',
|
||||
),
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No archive folder found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mark as unread is in popup menu, not a standalone button',
|
||||
(tester) async {
|
||||
testWidgets('Mark as unread is in popup menu, not a standalone button', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -401,13 +394,16 @@ void main() {
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository()),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emailDetail: testEmail(),
|
||||
emailBody:
|
||||
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
emailBody: const EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
attachments: [],
|
||||
),
|
||||
rawRfc822: rawContent,
|
||||
),
|
||||
),
|
||||
@@ -436,13 +432,16 @@ void main() {
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository()),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emailDetail: testEmail(),
|
||||
emailBody:
|
||||
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
emailBody: const EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
attachments: [],
|
||||
),
|
||||
rawRfc822: 'Subject: test\r\n\r\nBody',
|
||||
),
|
||||
),
|
||||
@@ -483,43 +482,37 @@ void main() {
|
||||
expect(find.text('Share'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'long-press on unsubscribe chip shows URL tooltip',
|
||||
(tester) async {
|
||||
final email = testEmail(
|
||||
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
testWidgets('long-press on unsubscribe chip shows URL tooltip', (
|
||||
tester,
|
||||
) async {
|
||||
final email = testEmail(
|
||||
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Unsubscribe'), findsOneWidget);
|
||||
expect(find.text('Unsubscribe'), findsOneWidget);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.longPress(find.text('Unsubscribe'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Unsubscribe'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('https://example.com/unsubscribe'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(find.text('https://example.com/unsubscribe'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Show Mail Structure opens dialog with MIME parts', (
|
||||
tester,
|
||||
@@ -563,36 +556,31 @@ void main() {
|
||||
expect(find.textContaining('application/pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Show Mail Structure shows snackbar when mimeTree is absent',
|
||||
(tester) async {
|
||||
const body = EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
textBody: 'Hello',
|
||||
attachments: [],
|
||||
// mimeTree is null — not yet cached or not available.
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(body: body),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', (
|
||||
tester,
|
||||
) async {
|
||||
const body = EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
textBody: 'Hello',
|
||||
attachments: [],
|
||||
// mimeTree is null — not yet cached or not available.
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(body: body),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Show Mail Structure'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Show Mail Structure'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('Structure not available'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(find.textContaining('Structure not available'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,46 +15,42 @@ Email _email({
|
||||
String subject = 'Hello world',
|
||||
bool isSeen = true,
|
||||
bool isFlagged = false,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: int.parse(id.split(':').last),
|
||||
subject: subject,
|
||||
receivedAt: _kDate,
|
||||
sentAt: _kDate,
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
}) => Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: int.parse(id.split(':').last),
|
||||
subject: subject,
|
||||
receivedAt: _kDate,
|
||||
sentAt: _kDate,
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
|
||||
List<Override> _overrides({
|
||||
List<Email> emails = const [],
|
||||
List<Email> searchResults = const [],
|
||||
String? syncError,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: emails, searchResults: searchResults),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith(
|
||||
(ref, _) => Stream.value(syncError),
|
||||
),
|
||||
];
|
||||
}) => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: emails, searchResults: searchResults),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('EmailListScreen goldens', () {
|
||||
@@ -122,9 +118,7 @@ void main() {
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: _overrides(
|
||||
searchResults: [
|
||||
_email(id: 'acc-1:5', subject: 'Project proposal'),
|
||||
],
|
||||
searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -27,8 +27,7 @@ class _MutableFakeEmailRepository extends FakeEmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async =>
|
||||
_results;
|
||||
) async => _results;
|
||||
}
|
||||
|
||||
final _kDate = DateTime(2024, 6);
|
||||
@@ -430,63 +429,62 @@ void main() {
|
||||
expect(find.text('Result email'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'deleting all search results pops back to previous screen',
|
||||
(tester) async {
|
||||
final email = testEmail(subject: 'Needle');
|
||||
testWidgets('deleting all search results pops back to previous screen', (
|
||||
tester,
|
||||
) async {
|
||||
final email = testEmail(subject: 'Needle');
|
||||
|
||||
// Start at the mailbox list so the email list is pushed on top of it,
|
||||
// making context.canPop() == true inside EmailListScreen.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(searchResults: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// Start at the mailbox list so the email list is pushed on top of it,
|
||||
// making context.canPop() == true inside EmailListScreen.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(searchResults: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
|
||||
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
||||
await tester.tap(find.text('INBOX'));
|
||||
await tester.pumpAndSettle();
|
||||
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
||||
await tester.tap(find.text('INBOX'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EmailListScreen), findsOneWidget);
|
||||
expect(find.byType(EmailListScreen), findsOneWidget);
|
||||
|
||||
// Search for the email.
|
||||
await tester.enterText(find.byType(TextField), 'Needle');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
// Search for the email.
|
||||
await tester.enterText(find.byType(TextField), 'Needle');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 'Needle' also appears in the SearchBar input, so match at least one.
|
||||
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
||||
// 'Needle' also appears in the SearchBar input, so match at least one.
|
||||
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
||||
|
||||
// Long-press the sender name (unique to the email tile) to enter
|
||||
// selection mode.
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
// Long-press the sender name (unique to the email tile) to enter
|
||||
// selection mode.
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.select_all));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.select_all));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.delete));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have popped back to the mailbox list.
|
||||
expect(find.byType(EmailListScreen), findsNothing);
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
},
|
||||
);
|
||||
// Should have popped back to the mailbox list.
|
||||
expect(find.byType(EmailListScreen), findsNothing);
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'deleting some search results updates the list without popping',
|
||||
|
||||
+78
-93
@@ -49,7 +49,7 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
|
||||
class FakeAccountRepository implements AccountRepository {
|
||||
FakeAccountRepository([List<Account>? accounts])
|
||||
: _accounts = List.of(accounts ?? []);
|
||||
: _accounts = List.of(accounts ?? []);
|
||||
|
||||
final List<Account> _accounts;
|
||||
bool hasPassword = true;
|
||||
@@ -137,8 +137,7 @@ class FakeDraftRepository implements DraftRepository {
|
||||
final matches = _drafts.values.where((d) {
|
||||
if (replyToEmailId == null) return d.replyToEmailId == null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -156,7 +155,7 @@ class FakeMailboxRepository implements MailboxRepository {
|
||||
final List<Mailbox> _mailboxes;
|
||||
|
||||
FakeMailboxRepository([List<Mailbox>? mailboxes])
|
||||
: _mailboxes = mailboxes ?? [];
|
||||
: _mailboxes = mailboxes ?? [];
|
||||
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
|
||||
@@ -206,52 +205,49 @@ class FakeEmailRepository implements EmailRepository {
|
||||
EmailBody? emailBody,
|
||||
List<Email>? searchResults,
|
||||
String rawRfc822 = '',
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
_rawRfc822 = rawRfc822,
|
||||
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
_rawRfc822 = rawRfc822,
|
||||
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value(List.of(_emails));
|
||||
}) => Stream.value(List.of(_emails));
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
observeEmails(accountId, mailboxPath).map((emails) {
|
||||
return emails.map((e) {
|
||||
return EmailThread(
|
||||
threadId: e.threadId ?? e.id,
|
||||
subject: e.subject,
|
||||
preview: e.preview,
|
||||
participants: e.from,
|
||||
latestDate: e.sentAt ?? e.receivedAt,
|
||||
messageCount: 1,
|
||||
hasUnread: !e.isSeen,
|
||||
isFlagged: e.isFlagged,
|
||||
latestEmailId: e.id,
|
||||
emailIds: [e.id],
|
||||
accountId: e.accountId,
|
||||
mailboxPath: e.mailboxPath,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}) => observeEmails(accountId, mailboxPath).map((emails) {
|
||||
return emails.map((e) {
|
||||
return EmailThread(
|
||||
threadId: e.threadId ?? e.id,
|
||||
subject: e.subject,
|
||||
preview: e.preview,
|
||||
participants: e.from,
|
||||
latestDate: e.sentAt ?? e.receivedAt,
|
||||
messageCount: 1,
|
||||
hasUnread: !e.isSeen,
|
||||
isFlagged: e.isFlagged,
|
||||
latestEmailId: e.id,
|
||||
emailIds: [e.id],
|
||||
accountId: e.accountId,
|
||||
mailboxPath: e.mailboxPath,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) =>
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
) => Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
@@ -263,8 +259,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<SyncEmailsResult> syncEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
SyncEmailsResult.zero;
|
||||
) async => SyncEmailsResult.zero;
|
||||
|
||||
@override
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
||||
@@ -290,8 +285,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async =>
|
||||
null;
|
||||
) async => null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String emailId) async => null;
|
||||
@@ -309,8 +303,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
) async => '/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => _rawRfc822;
|
||||
@@ -320,30 +313,26 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async =>
|
||||
_searchResults;
|
||||
) async => _searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
String query,
|
||||
) async =>
|
||||
_searchResults;
|
||||
) async => _searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
String address,
|
||||
) async =>
|
||||
[];
|
||||
) async => [];
|
||||
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
String? accountId,
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) async =>
|
||||
[];
|
||||
}) async => [];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
@@ -353,8 +342,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
) async => ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
@@ -553,28 +541,26 @@ List<Override> baseOverrides({
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
SyncHealthRow? syncHealth,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
accountDiscoveryServiceProvider.overrideWithValue(
|
||||
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
|
||||
),
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
// 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)),
|
||||
];
|
||||
}) => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
accountDiscoveryServiceProvider.overrideWithValue(
|
||||
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
|
||||
),
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
// 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
|
||||
@@ -604,23 +590,22 @@ Email testEmail({
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? listUnsubscribeHeader,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 42,
|
||||
subject: subject,
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
}) => Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 42,
|
||||
subject: subject,
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
|
||||
class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
FakeUserPreferencesRepository({
|
||||
@@ -635,12 +620,12 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
UserPreferences(
|
||||
menuPosition: menuPosition,
|
||||
mailViewButtonPosition: mailViewButtonPosition,
|
||||
afterMailViewAction: afterMailViewAction,
|
||||
),
|
||||
);
|
||||
UserPreferences(
|
||||
menuPosition: menuPosition,
|
||||
mailViewButtonPosition: mailViewButtonPosition,
|
||||
afterMailViewAction: afterMailViewAction,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(MenuPosition position) async {
|
||||
|
||||
@@ -89,9 +89,7 @@ void main() {
|
||||
expect(find.text('No results'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows email results under "Messages" section', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('shows email results under "Messages" section', (tester) async {
|
||||
final email = testEmail(subject: 'Invoice Q3');
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
@@ -122,9 +120,7 @@ void main() {
|
||||
expect(find.text('Invoice Q3'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows folder results under "Folders" section', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('shows folder results under "Folders" section', (tester) async {
|
||||
const archiveMailbox = Mailbox(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
|
||||
@@ -11,19 +11,21 @@ void _expectLightMode(String html) {
|
||||
}
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('buildEmailHtml', () {
|
||||
test('forces light color-scheme to prevent black-on-black in dark mode',
|
||||
() {
|
||||
_expectLightMode(buildEmailHtml('<p>Hello</p>'));
|
||||
});
|
||||
test(
|
||||
'forces light color-scheme to prevent black-on-black in dark mode',
|
||||
() {
|
||||
_expectLightMode(buildEmailHtml('<p>Hello</p>'));
|
||||
},
|
||||
);
|
||||
|
||||
test('includes email body content', () {
|
||||
final html = buildEmailHtml('<p>Test body</p>');
|
||||
@@ -42,10 +44,10 @@ void main() {
|
||||
_expectLightMode(html);
|
||||
});
|
||||
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
||||
() {
|
||||
final html =
|
||||
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off', () {
|
||||
final html = buildEmailHtml(
|
||||
'<table width="600"><tr><td>x</td></tr></table>',
|
||||
);
|
||||
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
||||
expect(html, contains('overflow-x: hidden'));
|
||||
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
|
||||
@@ -62,11 +64,7 @@ void main() {
|
||||
group('SecureEmailWebView (Linux plain-text fallback)', () {
|
||||
testWidgets('renders extracted text from HTML', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const SecureEmailWebView(
|
||||
htmlBody: '<p>Hello <b>world</b></p>',
|
||||
),
|
||||
),
|
||||
_wrap(const SecureEmailWebView(htmlBody: '<p>Hello <b>world</b></p>')),
|
||||
);
|
||||
expect(find.textContaining('Hello'), findsOneWidget);
|
||||
expect(find.textContaining('world'), findsOneWidget);
|
||||
@@ -92,12 +90,11 @@ void main() {
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('toggling loadRemoteImages rebuilds without error',
|
||||
(tester) async {
|
||||
testWidgets('toggling loadRemoteImages rebuilds without error', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
|
||||
),
|
||||
_wrap(const SecureEmailWebView(htmlBody: '<p>Body</p>')),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
@@ -111,9 +108,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('handles empty HTML body', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(const SecureEmailWebView(htmlBody: '')),
|
||||
);
|
||||
await tester.pumpWidget(_wrap(const SecureEmailWebView(htmlBody: '')));
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,13 +27,9 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sieveRepositoryProvider.overrideWith(
|
||||
(ref) => _FakeSieveRepository(),
|
||||
),
|
||||
sieveRepositoryProvider.overrideWith((ref) => _FakeSieveRepository()),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SieveScriptsScreen(accountId: 'acc-1'),
|
||||
),
|
||||
child: const MaterialApp(home: SieveScriptsScreen(accountId: 'acc-1')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -11,23 +11,22 @@ Email _threadEmail({
|
||||
String id = 'acc-1:10',
|
||||
bool isFlagged = false,
|
||||
bool isSeen = true,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 10,
|
||||
threadId: 'thread-1',
|
||||
subject: 'Project update',
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6, 1, 9),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
}) => Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 10,
|
||||
threadId: 'thread-1',
|
||||
subject: 'Project update',
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6, 1, 9),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('ThreadDetailScreen', () {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user