chore: migrate to SOPS and SSH for Dagger engine access

This commit is contained in:
Thomas Güttler
2026-06-02 11:10:29 +02:00
parent 9290d87a7f
commit 1e2d1b6063
103 changed files with 3416 additions and 3279 deletions
+4 -4
View File
@@ -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) {
+17 -26
View File
@@ -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,
);
}
+2 -1
View File
@@ -18,7 +18,8 @@ Future<void> initNotifications() async {
);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission();
_initialized = true;
} on MissingPluginException {
+15 -13
View File
@@ -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)}';
}
+2 -1
View File
@@ -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');
+2 -2
View File
@@ -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 {
+6 -4
View File
@@ -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);
}
+5 -11
View File
@@ -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();
+68 -64
View File
@@ -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) {
+13 -10
View File
@@ -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,
+5 -2
View File
@@ -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(),
+1 -4
View File
@@ -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
View File
@@ -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.',
);
}
+26 -30
View File
@@ -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)));
});
}
+9 -7
View File
@@ -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(
+16 -21
View File
@@ -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;
+34 -44
View File
@@ -9,18 +9,18 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart';
import 'package:sharedinbox/data/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
View File
@@ -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
View File
@@ -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) {
+15 -11
View File
@@ -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)));
}
},
);
+14 -18
View File
@@ -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'),
),
),
},
);
}
+10 -13
View File
@@ -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'),
),
);
},
+15 -14
View File
@@ -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),
+31 -32
View File
@@ -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)}',
),
);
},
),
);
}
}
+3 -2
View File
@@ -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());
+15 -16
View File
@@ -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 {
+3 -3
View File
@@ -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,
),
),
+12 -8
View File
@@ -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),
+3 -2
View File
@@ -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),
+46 -60
View File
@@ -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'),
+45 -54
View File
@@ -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
+9 -8
View File
@@ -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',
+10 -6
View File
@@ -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,
+10 -12
View File
@@ -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,
),
),
),
],
+34 -31
View File
@@ -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))),
],
),
);
}
+9 -5
View File
@@ -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,
+6 -8
View File
@@ -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.'),
+3 -9
View File
@@ -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,
),
],
+6 -4
View File
@@ -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'
+5 -3
View File
@@ -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(
+3 -5
View File
@@ -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 ?? '',
+20 -16
View File
@@ -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')));
}
}
}