fix: format, analyze-fix and update mocks

This commit is contained in:
Thomas Güttler
2026-06-02 17:10:16 +02:00
parent 3520f161e3
commit 8ea8d71f42
84 changed files with 1972 additions and 2201 deletions
+218 -216
View File
@@ -388,228 +388,231 @@ 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()),
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);',
),
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();
);
// 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().
@@ -660,8 +663,7 @@ 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.',
);
}
+19 -20
View File
@@ -11,7 +11,8 @@ class LocalSieveRepository {
Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select(
_db.localSieveScripts,
)..where((t) => t.accountId.equals(accountId))).get();
)..where((t) => t.accountId.equals(accountId)))
.get();
return rows
.map(
(r) => SieveScript(
@@ -26,11 +27,10 @@ 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;
}
@@ -46,16 +46,16 @@ class LocalSieveRepository {
await (_db.update(_db.localSieveScripts)
..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,
@@ -63,9 +63,7 @@ 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,
@@ -80,7 +78,8 @@ class LocalSieveRepository {
final rowId = int.parse(scriptId);
await (_db.delete(
_db.localSieveScripts,
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))).go();
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
.go();
}
Future<void> activateScript(String accountId, String scriptId) async {
+7 -9
View File
@@ -6,12 +6,11 @@ import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/utils/host_utils.dart';
import 'package:sharedinbox/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]
@@ -65,9 +64,8 @@ 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(
+21 -16
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,9 +67,12 @@ 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;
}
@@ -215,9 +218,12 @@ 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})');
}
@@ -240,8 +246,7 @@ 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;
+44 -34
View File
@@ -9,18 +9,18 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart';
import 'package:sharedinbox/data/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,13 +51,16 @@ 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) {
@@ -123,9 +126,12 @@ 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>?;
@@ -164,16 +170,19 @@ 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)) {
@@ -192,13 +201,16 @@ 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,
);
});
}
@@ -219,9 +231,8 @@ 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),
@@ -247,9 +258,8 @@ 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,15 +23,14 @@ 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,
@@ -59,7 +58,8 @@ 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,
);
}
@@ -10,7 +10,7 @@ import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
: _imapConnect = imapConnect;
: _imapConnect = imapConnect;
final AppDatabase _db;
final AccountRepository _accounts;
@@ -51,9 +51,7 @@ 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),
@@ -94,7 +92,8 @@ 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);
}
@@ -111,9 +110,8 @@ 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);
@@ -134,11 +132,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()
@@ -152,8 +150,8 @@ 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;
@@ -166,12 +164,11 @@ class DraftRepositoryImpl implements DraftRepository {
// 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();
@@ -182,9 +179,7 @@ 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 +205,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,13 +45,12 @@ 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);
}
@@ -85,7 +84,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
// 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();
)..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) {
@@ -111,9 +111,7 @@ 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,
@@ -218,7 +216,8 @@ 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);
@@ -239,9 +238,7 @@ 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,
@@ -258,13 +255,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;
}
@@ -273,9 +270,7 @@ 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,
@@ -304,14 +299,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) {
@@ -328,7 +323,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
Future<void> clearForResync(String accountId) async {
await (_db.delete(
_db.mailboxes,
)..where((t) => t.accountId.equals(accountId))).go();
)..where((t) => t.accountId.equals(accountId)))
.go();
}
@override
@@ -364,9 +360,7 @@ 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,
@@ -377,7 +371,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id))).getSingle();
)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
@@ -419,9 +414,7 @@ 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,
@@ -432,7 +425,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(dbId))).getSingle();
)..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
}
@@ -10,11 +10,10 @@ 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();
}
@@ -27,11 +26,10 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
// Remove existing entry for same query (deduplication).
await (_db.delete(
_db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed))).go();
)..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(),
@@ -39,17 +37,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();
)..where((t) => t.id.isNotIn(keepIds)))
.go();
}
});
}
@@ -23,9 +23,7 @@ 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),
@@ -44,7 +42,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
final keyIdHex = _hex(keyId);
final row = await (_db.select(
_db.shareKeys,
)..where((t) => t.id.equals(keyIdHex))).getSingleOrNull();
)..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull();
if (row == null) return null;
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
@@ -58,8 +57,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
Future<void> _pruneExpired() async {
await (_db.delete(
_db.shareKeys,
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
_db.shareKeys,
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
.go();
}
@@ -27,9 +27,7 @@ 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',
@@ -48,9 +46,7 @@ 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,
@@ -74,11 +70,10 @@ 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,9 +11,7 @@ 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,
@@ -31,11 +29,10 @@ 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>,
@@ -13,14 +13,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId))).watchSingleOrNull().map(_rowToModel);
)..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),
@@ -30,9 +30,7 @@ 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),
@@ -44,9 +42,7 @@ 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),