fix: format, analyze-fix and update mocks
This commit is contained in:
+1
-1
@@ -181,7 +181,7 @@ func New(
|
||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||
func (m *Ci) toolchain() *dagger.Container {
|
||||
return dag.Container().
|
||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||
From("ghcr.io/cirruslabs/flutter:3.44.0").
|
||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||
|
||||
@@ -35,9 +35,8 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
|
||||
try {
|
||||
final url = Uri.https(domain, '/.well-known/jmap');
|
||||
final request = http.Request('GET', url)..followRedirects = false;
|
||||
final streamed = await _client
|
||||
.send(request)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
final streamed =
|
||||
await _client.send(request).timeout(const Duration(seconds: 5));
|
||||
|
||||
String sessionUrl;
|
||||
if (streamed.statusCode >= 300 && streamed.statusCode < 400) {
|
||||
|
||||
@@ -6,14 +6,19 @@ 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({
|
||||
typedef ManageSieveConnectForTestFn = Future<ManageSieveClient> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
@@ -23,7 +28,8 @@ 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.
|
||||
@@ -156,9 +162,12 @@ 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,8 +4,7 @@ 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({
|
||||
typedef ManageSieveProbeFn = Future<bool> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
|
||||
@@ -18,8 +18,7 @@ Future<void> initNotifications() async {
|
||||
);
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
_initialized = true;
|
||||
} on MissingPluginException {
|
||||
|
||||
@@ -166,8 +166,7 @@ class ShareEncryptionService {
|
||||
final cipherBytes = Uint8List.fromList(box.cipherText);
|
||||
final macBytes = Uint8List.fromList(box.mac.bytes);
|
||||
|
||||
final out =
|
||||
Uint8List(
|
||||
final out = Uint8List(
|
||||
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
||||
)
|
||||
..setAll(0, recipientKeyId)
|
||||
|
||||
@@ -62,8 +62,7 @@ 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');
|
||||
|
||||
|
||||
@@ -64,8 +64,7 @@ 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 &&
|
||||
_ => rule.conditions.length == 1 &&
|
||||
_evalCondition(rule.conditions.first, email),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -379,9 +379,8 @@ 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 {
|
||||
@@ -401,8 +400,7 @@ class _AccountSync implements _SyncLoop {
|
||||
e.newMessagesExists > e.oldMessagesExists) {
|
||||
hasNewMail = true;
|
||||
}
|
||||
if (!newMessageCompleter.isCompleted)
|
||||
newMessageCompleter.complete();
|
||||
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
||||
});
|
||||
|
||||
await client.idleStart();
|
||||
@@ -642,9 +640,7 @@ 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(
|
||||
final pushSub = _emails.watchJmapPush(account.id, password).listen(
|
||||
(_) {
|
||||
if (!pushReady.isCompleted) pushReady.complete();
|
||||
},
|
||||
|
||||
@@ -83,9 +83,8 @@ 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(
|
||||
@@ -94,8 +93,8 @@ Future<void> _checkAccount(
|
||||
);
|
||||
final currentUidNext = status.uidNext;
|
||||
|
||||
final stored =
|
||||
await (db.select(db.syncStates)..where(
|
||||
final stored = await (db.select(db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.resourceType.equals(_kResourceType),
|
||||
@@ -103,9 +102,7 @@ Future<void> _checkAccount(
|
||||
.getSingleOrNull();
|
||||
final lastUidNext = _parseUidNext(stored?.state);
|
||||
|
||||
await db
|
||||
.into(db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await db.into(db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: account.id,
|
||||
resourceType: _kResourceType,
|
||||
|
||||
@@ -76,14 +76,11 @@ 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(),
|
||||
|
||||
@@ -606,7 +606,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
);
|
||||
}
|
||||
if (from >= 34 && from < 36) {
|
||||
await m.addColumn(userPreferences, userPreferences.afterMailViewAction);
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.afterMailViewAction,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 +27,7 @@ class LocalSieveRepository {
|
||||
|
||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||
final rowId = int.parse(blobId);
|
||||
final row =
|
||||
await (_db.select(
|
||||
final row = await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||
.getSingleOrNull();
|
||||
@@ -51,8 +51,8 @@ class LocalSieveRepository {
|
||||
content: Value(content),
|
||||
),
|
||||
);
|
||||
final updated =
|
||||
await (_db.select(_db.localSieveScripts)..where(
|
||||
final updated = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,8 +6,7 @@ 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(
|
||||
typedef ImapConnectFn = Future<ImapClient> Function(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ 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({
|
||||
typedef ManageSieveConnectFn = Future<ManageSieveClient> Function({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
@@ -20,7 +19,8 @@ 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',
|
||||
],
|
||||
], 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([
|
||||
final responses = await jmap.call(
|
||||
[
|
||||
['SieveScript/set', setArgs, '0'],
|
||||
], withSieve: true);
|
||||
],
|
||||
withSieve: true,
|
||||
);
|
||||
final result = _responseArgs(responses, 0, 'SieveScript/set');
|
||||
if (id == null) {
|
||||
final created = result['created'] as Map<String, dynamic>?;
|
||||
@@ -164,7 +170,8 @@ class SieveRepository {
|
||||
return;
|
||||
}
|
||||
await _withJmap(account, (jmap) async {
|
||||
final responses = await jmap.call([
|
||||
final responses = await jmap.call(
|
||||
[
|
||||
[
|
||||
'SieveScript/set',
|
||||
{
|
||||
@@ -173,7 +180,9 @@ class SieveRepository {
|
||||
},
|
||||
'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',
|
||||
],
|
||||
], 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),
|
||||
|
||||
@@ -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,8 +132,8 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
final messageCount = selectResult.messagesExists;
|
||||
|
||||
// Upload local drafts that have no server counterpart.
|
||||
final localDrafts =
|
||||
await (_db.select(_db.drafts)..where(
|
||||
final localDrafts = await (_db.select(_db.drafts)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
|
||||
))
|
||||
.get();
|
||||
@@ -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,10 +164,9 @@ 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(),
|
||||
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();
|
||||
@@ -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)),
|
||||
|
||||
@@ -22,8 +22,7 @@ import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||
|
||||
typedef SmtpConnectFn =
|
||||
Future<imap.SmtpClient> Function(
|
||||
typedef SmtpConnectFn = Future<imap.SmtpClient> Function(
|
||||
account_model.Account account,
|
||||
String username,
|
||||
String password,
|
||||
@@ -132,8 +131,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) async {
|
||||
final threadEmails =
|
||||
await (_db.select(_db.emails)
|
||||
final threadEmails = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
@@ -147,7 +145,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.get();
|
||||
|
||||
if (threadEmails.isEmpty) {
|
||||
await (_db.delete(_db.threads)..where(
|
||||
await (_db.delete(_db.threads)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath) &
|
||||
@@ -173,9 +172,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
|
||||
await _db
|
||||
.into(_db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.threads).insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: threadId,
|
||||
accountId: accountId,
|
||||
@@ -199,7 +196,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<model.Email?> getEmail(String emailId) async {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@@ -211,7 +209,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<model.EmailBody> getEmailBody(String emailId) async {
|
||||
final cached = await (_db.select(
|
||||
_db.emailBodies,
|
||||
)..where((t) => t.emailId.equals(emailId))).getSingleOrNull();
|
||||
)..where((t) => t.emailId.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (cached != null) {
|
||||
// Re-fetch if cachedAt is null (legacy row) or older than the TTL.
|
||||
final age = cached.cachedAt == null
|
||||
@@ -222,7 +221,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final emailRow = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingle();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
|
||||
@@ -246,9 +246,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
final textBody = msg.decodeTextPlainPart();
|
||||
final rawHtml = msg.decodeTextHtmlPart();
|
||||
final htmlBody = rawHtml == null
|
||||
? null
|
||||
: injectInlineImages(rawHtml, msg);
|
||||
final htmlBody =
|
||||
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
|
||||
final contentInfos = msg.findContentInfo();
|
||||
|
||||
final attachmentsJson = jsonEncode(
|
||||
@@ -257,8 +256,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
(a) => {
|
||||
'filename': a.fileName ?? '',
|
||||
'contentType': a.contentType?.mediaType.text ?? '',
|
||||
'size':
|
||||
a.size ??
|
||||
'size': a.size ??
|
||||
msg.getPart(a.fetchId)?.decodeContentBinary()?.length ??
|
||||
0,
|
||||
'fetchPartId': a.fetchId,
|
||||
@@ -275,9 +273,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final mimeTreeJson = _buildMimeTreeJson(msg);
|
||||
|
||||
await _db
|
||||
.into(_db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: Value(textBody),
|
||||
@@ -361,9 +357,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure))
|
||||
: null;
|
||||
|
||||
await _db
|
||||
.into(_db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: Value(textBody),
|
||||
@@ -415,8 +409,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
try {
|
||||
// Only request CONDSTORE if the server advertises it. Servers that don't
|
||||
// support the extension may reject SELECT with (CONDSTORE) with BAD.
|
||||
final supportsCondStore =
|
||||
client.serverInfo.supports('CONDSTORE') ||
|
||||
final supportsCondStore = client.serverInfo.supports('CONDSTORE') ||
|
||||
client.serverInfo.supports('QRESYNC');
|
||||
final selectedMailbox = await client.selectMailboxByPath(
|
||||
mailboxPath,
|
||||
@@ -431,7 +424,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// First run or UID validity changed — full sync.
|
||||
if (checkpoint != null) {
|
||||
// UID validity changed: remove stale local emails for this mailbox.
|
||||
await (_db.delete(_db.emails)..where(
|
||||
await (_db.delete(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
@@ -440,10 +434,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
// Use UID SEARCH ALL + UID FETCH so every message gets a reliable UID.
|
||||
// Regular FETCH 1:* may not populate msg.uid on all servers.
|
||||
final allUids =
|
||||
(await client.uidSearchMessages(
|
||||
final allUids = (await client.uidSearchMessages(
|
||||
searchCriteria: 'ALL',
|
||||
)).matchingSequence?.toList() ??
|
||||
))
|
||||
.matchingSequence
|
||||
?.toList() ??
|
||||
[];
|
||||
var bytes = 0;
|
||||
if (allUids.isNotEmpty) {
|
||||
@@ -477,10 +472,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// (including Stalwart 0.14.x) do not increment HIGHESTMODSEQ when new
|
||||
// mail is delivered via SMTP, causing newly arrived messages to be
|
||||
// silently missed when modseq values appear equal.
|
||||
final newUids =
|
||||
(await client.uidSearchMessages(
|
||||
final newUids = (await client.uidSearchMessages(
|
||||
searchCriteria: 'UID ${lastUid + 1}:*',
|
||||
)).matchingSequence?.toList() ??
|
||||
))
|
||||
.matchingSequence
|
||||
?.toList() ??
|
||||
[];
|
||||
var bytes = 0;
|
||||
if (newUids.isNotEmpty) {
|
||||
@@ -500,15 +496,15 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
// Detect remote deletions.
|
||||
final serverUids =
|
||||
(await client.uidSearchMessages(
|
||||
final serverUids = (await client.uidSearchMessages(
|
||||
searchCriteria: 'ALL',
|
||||
)).matchingSequence?.toList() ??
|
||||
))
|
||||
.matchingSequence
|
||||
?.toList() ??
|
||||
[];
|
||||
await _reconcileDeletedImap(account.id, mailboxPath, serverUids);
|
||||
final maxUid = serverUids.isEmpty
|
||||
? lastUid
|
||||
: serverUids.reduce(math.max);
|
||||
final maxUid =
|
||||
serverUids.isEmpty ? lastUid : serverUids.reduce(math.max);
|
||||
await _saveImapCheckpoint(
|
||||
account.id,
|
||||
resourceType,
|
||||
@@ -604,8 +600,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final inReplyTo = envelope.inReplyTo?.trim();
|
||||
final refs = msg.getHeaderValue('References')?.trim();
|
||||
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
|
||||
final threadId =
|
||||
_computeThreadId(
|
||||
final threadId = _computeThreadId(
|
||||
emailId: emailId,
|
||||
messageId: msgId,
|
||||
inReplyTo: inReplyTo,
|
||||
@@ -628,9 +623,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
|
||||
await _db
|
||||
.into(_db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: emailId,
|
||||
accountId: account.id,
|
||||
@@ -668,8 +661,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async {
|
||||
final rows =
|
||||
await (_db.select(_db.pendingChanges)..where(
|
||||
final rows = await (_db.select(_db.pendingChanges)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.resourceType.equals('Email') &
|
||||
@@ -719,8 +712,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String mailboxPath,
|
||||
List<int> serverUids,
|
||||
) async {
|
||||
final localRows =
|
||||
await (_db.select(_db.emails)..where(
|
||||
final localRows = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
@@ -781,15 +774,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final serverUids =
|
||||
(await client.uidSearchMessages(
|
||||
final serverUids = (await client.uidSearchMessages(
|
||||
searchCriteria: 'ALL',
|
||||
)).matchingSequence?.toList() ??
|
||||
))
|
||||
.matchingSequence
|
||||
?.toList() ??
|
||||
[];
|
||||
final serverUidSet = serverUids.toSet();
|
||||
|
||||
final localRows =
|
||||
await (_db.select(_db.emails)..where(
|
||||
final localRows = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
@@ -888,8 +882,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
final serverIdSet = allServerIds.toSet();
|
||||
|
||||
final localRows =
|
||||
await (_db.select(_db.emails)..where(
|
||||
final localRows = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.mailboxPath.equals(mailboxJmapId),
|
||||
@@ -1193,9 +1187,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final jmapListUnsubscribe =
|
||||
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
|
||||
|
||||
await _db
|
||||
.into(_db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: dbId,
|
||||
accountId: accountId,
|
||||
@@ -1223,9 +1215,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// Cache body if the server included bodyValues in this response.
|
||||
if (m.containsKey('bodyValues')) {
|
||||
final (textBody, htmlBody, attachmentsJson) = _parseJmapBody(m);
|
||||
await _db
|
||||
.into(_db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: dbId,
|
||||
textBody: Value(textBody),
|
||||
@@ -1300,11 +1290,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
if (next >= _maxChangeAttempts) {
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).go();
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
} else {
|
||||
await (_db.update(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).write(
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.write(
|
||||
PendingChangesCompanion(
|
||||
attempts: Value(next),
|
||||
lastError: Value(error.toString()),
|
||||
@@ -1316,8 +1308,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// ── sync_state helpers ────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _loadSyncState(String accountId, String resourceType) async {
|
||||
final row =
|
||||
await (_db.select(_db.syncStates)..where(
|
||||
final row = await (_db.select(_db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.resourceType.equals(resourceType),
|
||||
@@ -1331,9 +1323,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String resourceType,
|
||||
String state,
|
||||
) async {
|
||||
await _db
|
||||
.into(_db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: accountId,
|
||||
resourceType: resourceType,
|
||||
@@ -1483,7 +1473,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
@@ -1559,8 +1550,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final unread =
|
||||
await (_db.select(_db.emails)..where(
|
||||
final unread = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath) &
|
||||
@@ -1593,7 +1584,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
// Bulk mark all unread emails in this mailbox as seen.
|
||||
await (_db.update(_db.emails)..where(
|
||||
await (_db.update(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath) &
|
||||
@@ -1602,7 +1594,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.write(const EmailsCompanion(isSeen: Value(true)));
|
||||
|
||||
// Update all threads in this mailbox to reflect no unread.
|
||||
await (_db.update(_db.threads)..where(
|
||||
await (_db.update(_db.threads)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
@@ -1615,7 +1608,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
@@ -1683,13 +1677,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<String?> deleteEmail(String emailId) async {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingleOrNull();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
// Move to Trash when possible so the user can recover the message.
|
||||
final trashRow =
|
||||
await (_db.select(_db.mailboxes)
|
||||
final trashRow = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(account.id) & t.role.equals('trash'),
|
||||
)
|
||||
@@ -1741,9 +1735,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String changeType,
|
||||
String payload,
|
||||
) async {
|
||||
await _db
|
||||
.into(_db.pendingChanges)
|
||||
.insert(
|
||||
await _db.into(_db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: accountId,
|
||||
resourceType: 'Email',
|
||||
@@ -1774,7 +1766,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
if (row != null) {
|
||||
final count = await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).go();
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
return count > 0;
|
||||
}
|
||||
return false;
|
||||
@@ -1784,24 +1777,21 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<void> snoozeEmail(String emailId, DateTime until) async {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingle();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
// Find or create Snoozed mailbox.
|
||||
var snoozedMailbox =
|
||||
await (_db.select(_db.mailboxes)
|
||||
var snoozedMailbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) & t.role.equals('snoozed'),
|
||||
(t) => t.accountId.equals(account.id) & t.role.equals('snoozed'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
snoozedMailbox ??=
|
||||
await (_db.select(_db.mailboxes)
|
||||
snoozedMailbox ??= await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) & t.name.equals('Snoozed'),
|
||||
(t) => t.accountId.equals(account.id) & t.name.equals('Snoozed'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
@@ -1841,8 +1831,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
@override
|
||||
Future<int> wakeUpEmails(String accountId) async {
|
||||
final now = DateTime.now();
|
||||
final expired =
|
||||
await (_db.select(_db.emails)..where(
|
||||
final expired = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.snoozedUntil.isSmallerOrEqualValue(now),
|
||||
@@ -1853,8 +1843,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
for (final row in expired) {
|
||||
// Per instructions: "get to inbox moved by app".
|
||||
final inbox =
|
||||
await (_db.select(_db.mailboxes)
|
||||
final inbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
||||
)
|
||||
@@ -1890,12 +1879,10 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async {
|
||||
final row =
|
||||
await (_db.select(_db.emails)
|
||||
final row = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.messageId.equals(messageId),
|
||||
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
@@ -1905,9 +1892,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
@override
|
||||
Future<void> restoreEmails(List<model.Email> emails) async {
|
||||
for (final e in emails) {
|
||||
await _db
|
||||
.into(_db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: e.id,
|
||||
accountId: e.accountId,
|
||||
@@ -1939,8 +1924,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
/// been processed yet. See [EmailRepository.applySieveRules] for details.
|
||||
@override
|
||||
Future<int> applySieveRules(String accountId) async {
|
||||
final scriptRow =
|
||||
await (_db.select(_db.localSieveScripts)
|
||||
final scriptRow = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.isActive.equals(true),
|
||||
)
|
||||
@@ -1957,8 +1941,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
if (rules.isEmpty) return 0;
|
||||
|
||||
final inboxMailbox =
|
||||
await (_db.select(_db.mailboxes)
|
||||
final inboxMailbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
||||
)
|
||||
@@ -1968,11 +1951,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final alreadyApplied = await (_db.select(
|
||||
_db.localSieveApplied,
|
||||
)..where((t) => t.accountId.equals(accountId))).get();
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||
|
||||
final inboxEmails =
|
||||
await (_db.select(_db.emails)..where(
|
||||
final inboxEmails = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(inboxPath) &
|
||||
@@ -2020,14 +2004,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String formatAddrs(String json) {
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list
|
||||
.map((e) {
|
||||
return list.map((e) {
|
||||
final m = e as Map<String, dynamic>;
|
||||
final name = m['name'] as String? ?? '';
|
||||
final email = m['email'] as String? ?? '';
|
||||
return name.isEmpty ? email : '$name <$email>';
|
||||
})
|
||||
.join(', ');
|
||||
}).join(', ');
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
@@ -2046,9 +2028,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
Future<void> _markSieveApplied(String accountId, String messageId) async {
|
||||
await _db
|
||||
.into(_db.localSieveApplied)
|
||||
.insertOnConflictUpdate(
|
||||
await _db.into(_db.localSieveApplied).insertOnConflictUpdate(
|
||||
LocalSieveAppliedCompanion.insert(
|
||||
accountId: accountId,
|
||||
messageId: messageId,
|
||||
@@ -2064,8 +2044,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
) async {
|
||||
String destPath;
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
final destMailbox =
|
||||
await (_db.select(_db.mailboxes)
|
||||
final destMailbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(account.id) & t.name.equals(folder),
|
||||
)
|
||||
@@ -2160,8 +2139,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
/// Called at the start of each sync cycle. Returns count of applied changes.
|
||||
@override
|
||||
Future<int> flushPendingChanges(String accountId, String password) async {
|
||||
final rows =
|
||||
await (_db.select(_db.pendingChanges)
|
||||
final rows = await (_db.select(_db.pendingChanges)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||
.get();
|
||||
@@ -2203,7 +2181,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).go();
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
applied++;
|
||||
// Keep our checkpoint in sync with whatever the server returned.
|
||||
if (newState != null) {
|
||||
@@ -2213,7 +2192,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// Server rejected the mutation because our state token is stale.
|
||||
// Drop the cached state so the next sync cycle does a full re-fetch,
|
||||
// after which this change will be retried with a fresh token.
|
||||
await (_db.delete(_db.syncStates)..where(
|
||||
await (_db.delete(_db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(account.id) &
|
||||
t.resourceType.equals('Email'),
|
||||
@@ -2230,7 +2210,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// the change so the queue doesn't grow unboundedly.
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).go();
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
log('JMAP permanent error for change ${row.id}: $e');
|
||||
} catch (e) {
|
||||
await _recordChangeError(row, e);
|
||||
@@ -2265,7 +2246,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
await _applyPendingChangeImap(client, row);
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).go();
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
applied++;
|
||||
} catch (e) {
|
||||
if (_isImapNotFoundError(e)) {
|
||||
@@ -2273,7 +2255,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// pending change doesn't accumulate or block future changes.
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id))).go();
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
applied++;
|
||||
log('IMAP change ${row.id} skipped: message already gone ($e)');
|
||||
} else {
|
||||
@@ -2457,9 +2440,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
]);
|
||||
final createResult = _responseArgs(createResps, 0, 'Mailbox/set');
|
||||
final created = createResult['created'] as Map<String, dynamic>?;
|
||||
final newId =
|
||||
(created?['new-snoozed'] as Map<String, dynamic>?)?['id']
|
||||
as String?;
|
||||
final newId = (created?['new-snoozed']
|
||||
as Map<String, dynamic>?)?['id'] as String?;
|
||||
if (newId != null) destMailboxId = newId;
|
||||
}
|
||||
responses = await jmap.call([
|
||||
@@ -2646,8 +2628,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
// Look up the Sent mailbox JMAP ID from the local DB.
|
||||
final sentMailbox =
|
||||
await (_db.select(_db.mailboxes)
|
||||
final sentMailbox = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(account.id) & t.role.equals('sent'),
|
||||
)
|
||||
@@ -2730,7 +2711,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
// Then submit the created email.
|
||||
final submissionResponses = await jmap.call([
|
||||
final submissionResponses = await jmap.call(
|
||||
[
|
||||
[
|
||||
'EmailSubmission/set',
|
||||
{
|
||||
@@ -2748,7 +2730,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
},
|
||||
'1',
|
||||
],
|
||||
], withSubmission: true);
|
||||
],
|
||||
withSubmission: true,
|
||||
);
|
||||
|
||||
// Check EmailSubmission/set for submission errors.
|
||||
final subResult = _responseArgs(
|
||||
@@ -2795,7 +2779,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final emailRow = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingle();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
|
||||
@@ -2849,7 +2834,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<String> fetchRawRfc822(String emailId) async {
|
||||
final emailRow = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId))).getSingle();
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
|
||||
@@ -2924,8 +2910,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
: [Variable<String>(ftsQuery)];
|
||||
|
||||
final queryRows = await _db
|
||||
.customSelect(sql, variables: variables, readsFrom: {_db.emails})
|
||||
.get();
|
||||
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
);
|
||||
@@ -2953,15 +2938,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String address,
|
||||
) async {
|
||||
final pattern = '%${address.toLowerCase()}%';
|
||||
final rows =
|
||||
await (_db.select(_db.emails)
|
||||
final rows = await (_db.select(_db.emails)
|
||||
..where((t) {
|
||||
Expression<bool> condition = const Constant(true);
|
||||
if (accountId != null) {
|
||||
condition = t.accountId.equals(accountId);
|
||||
}
|
||||
condition =
|
||||
condition &
|
||||
condition = condition &
|
||||
(t.fromJson.like(pattern) |
|
||||
t.toAddresses.like(pattern) |
|
||||
t.ccJson.like(pattern));
|
||||
@@ -2980,13 +2963,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}) async {
|
||||
if (query.length < 2) return [];
|
||||
final pattern = '%${query.toLowerCase()}%';
|
||||
final rows =
|
||||
await (_db.select(_db.emails)
|
||||
final rows = await (_db.select(_db.emails)
|
||||
..where((t) {
|
||||
Expression<bool> cond = const Constant(true);
|
||||
if (accountId != null) cond = t.accountId.equals(accountId);
|
||||
cond =
|
||||
cond &
|
||||
cond = cond &
|
||||
(t.fromJson.like(pattern) |
|
||||
t.toAddresses.like(pattern) |
|
||||
t.ccJson.like(pattern));
|
||||
@@ -3035,16 +3016,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final terms = query
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
final searchCriteria = terms
|
||||
.map((term) {
|
||||
final terms =
|
||||
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||
final searchCriteria = terms.map((term) {
|
||||
final escaped = term.replaceAll('"', '\\"');
|
||||
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
|
||||
})
|
||||
.join(' ');
|
||||
}).join(' ');
|
||||
final result = await client.uidSearchMessages(
|
||||
searchCriteria: searchCriteria,
|
||||
);
|
||||
@@ -3076,8 +3053,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
||||
hasAttachment: msg.hasAttachments(),
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
}).toList();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
@@ -3286,13 +3262,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(
|
||||
_db.emails,
|
||||
)..where((t) => t.accountId.equals(accountId))).go();
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.accountId.equals(accountId))).go();
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.syncStates,
|
||||
)..where((t) => t.accountId.equals(accountId))).go();
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
});
|
||||
} finally {
|
||||
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||
@@ -3304,10 +3283,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Map<String, dynamic> _mimePartToJson(imap.MimePart part) {
|
||||
final ct = part.getHeaderContentType();
|
||||
final disposition = part.getHeaderContentDisposition();
|
||||
final rawEncoding = part
|
||||
.getHeader('content-transfer-encoding')
|
||||
?.firstOrNull
|
||||
?.value;
|
||||
final rawEncoding =
|
||||
part.getHeader('content-transfer-encoding')?.firstOrNull?.value;
|
||||
final encoding = rawEncoding?.split(';').first.trim().toLowerCase();
|
||||
return {
|
||||
'contentType': ct?.mediaType.text ?? 'application/octet-stream',
|
||||
|
||||
@@ -45,8 +45,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
String accountId,
|
||||
String role,
|
||||
) async {
|
||||
final row =
|
||||
await (_db.select(_db.mailboxes)
|
||||
final row = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals(role),
|
||||
)
|
||||
@@ -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,8 +255,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
// ── sync_state helpers ────────────────────────────────────────────────────
|
||||
|
||||
Future<String?> _loadSyncState(String accountId, String resourceType) async {
|
||||
final row =
|
||||
await (_db.select(_db.syncStates)..where(
|
||||
final row = await (_db.select(_db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.resourceType.equals(resourceType),
|
||||
@@ -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,
|
||||
@@ -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,8 +10,7 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
|
||||
@override
|
||||
Future<List<String>> getRecentSearches() async {
|
||||
final rows =
|
||||
await (_db.select(_db.searchHistoryEntries)
|
||||
final rows = await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.get();
|
||||
@@ -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,8 +37,7 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
);
|
||||
|
||||
// Prune to the most recent _maxEntries.
|
||||
final keepIds =
|
||||
await (_db.select(_db.searchHistoryEntries)
|
||||
final keepIds = await (_db.select(_db.searchHistoryEntries)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||
..limit(_maxEntries))
|
||||
.map((r) => r.id)
|
||||
@@ -49,7 +46,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
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;
|
||||
|
||||
@@ -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,8 +70,7 @@ 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)
|
||||
final mailboxRows = await (_db.select(_db.syncLogMailboxes)
|
||||
..where((t) => t.syncLogId.equals(r.id))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.mailboxPath)]))
|
||||
.get();
|
||||
|
||||
@@ -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,8 +29,7 @@ class UndoRepositoryImpl implements UndoRepository {
|
||||
|
||||
@override
|
||||
Future<List<UndoAction>> getHistory({int limit = 10}) async {
|
||||
final rows =
|
||||
await (_db.select(_db.undoActions)
|
||||
final rows = await (_db.select(_db.undoActions)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
|
||||
..limit(limit))
|
||||
.get();
|
||||
|
||||
@@ -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),
|
||||
|
||||
+11
-13
@@ -111,8 +111,8 @@ final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLastErrorProvider = StreamProvider.autoDispose
|
||||
.family<String?, String>((ref, accountId) {
|
||||
final syncLastErrorProvider =
|
||||
StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
|
||||
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
|
||||
});
|
||||
|
||||
@@ -127,12 +127,13 @@ final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
|
||||
return runner;
|
||||
});
|
||||
|
||||
final syncHealthProvider = StreamProvider.autoDispose
|
||||
.family<SyncHealthRow?, String>((ref, accountId) {
|
||||
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();
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
.watchSingleOrNull();
|
||||
});
|
||||
|
||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||
@@ -214,12 +215,9 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
}
|
||||
}
|
||||
|
||||
final accountByIdProvider = StreamProvider.autoDispose
|
||||
.family<model.Account?, String>((ref, accountId) {
|
||||
return ref
|
||||
.watch(accountRepositoryProvider)
|
||||
.observeAccounts()
|
||||
.map(
|
||||
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,
|
||||
@@ -227,8 +225,8 @@ final accountByIdProvider = StreamProvider.autoDispose
|
||||
);
|
||||
});
|
||||
|
||||
final accountConnectionStatusProvider = FutureProvider.autoDispose
|
||||
.family<void, String>((ref, accountId) async {
|
||||
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');
|
||||
|
||||
@@ -153,12 +153,10 @@ 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')),
|
||||
|
||||
@@ -117,10 +117,8 @@ 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;
|
||||
|
||||
|
||||
@@ -494,8 +494,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator:
|
||||
validator ??
|
||||
validator: validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
|
||||
@@ -62,7 +62,8 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
color:
|
||||
e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
title: Text(sender),
|
||||
subtitle: Text(
|
||||
|
||||
@@ -70,8 +70,7 @@ 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());
|
||||
@@ -82,10 +81,8 @@ 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;
|
||||
@@ -224,9 +221,8 @@ 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
|
||||
@@ -399,9 +395,8 @@ 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 {
|
||||
|
||||
@@ -117,8 +117,7 @@ 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;
|
||||
@@ -139,12 +138,10 @@ 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,
|
||||
);
|
||||
}
|
||||
@@ -395,8 +392,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator:
|
||||
validator ??
|
||||
validator: validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
|
||||
@@ -55,8 +55,7 @@ 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(
|
||||
@@ -94,9 +93,7 @@ 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,
|
||||
@@ -324,9 +321,8 @@ 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
|
||||
@@ -340,9 +336,8 @@ 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>{};
|
||||
@@ -445,9 +440,7 @@ 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,
|
||||
@@ -483,9 +476,7 @@ 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,
|
||||
@@ -522,14 +513,12 @@ 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;
|
||||
|
||||
@@ -559,9 +548,7 @@ 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,
|
||||
|
||||
@@ -182,9 +182,8 @@ 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,
|
||||
@@ -466,7 +465,9 @@ 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);
|
||||
@@ -527,7 +528,9 @@ 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) {
|
||||
@@ -577,9 +580,8 @@ 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;
|
||||
|
||||
@@ -611,7 +613,9 @@ 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);
|
||||
@@ -642,7 +646,9 @@ 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);
|
||||
@@ -683,10 +689,8 @@ 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(
|
||||
@@ -698,9 +702,8 @@ 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(
|
||||
@@ -773,9 +776,8 @@ 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,
|
||||
@@ -797,7 +799,9 @@ 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
|
||||
|
||||
@@ -84,8 +84,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
emailRepo.getEmailsByAddress(widget.accountId, query),
|
||||
).wait;
|
||||
|
||||
final matchedMailboxes =
|
||||
allMailboxes.where((m) => _hasWordPrefix(m.name, ql)).toList()
|
||||
final matchedMailboxes = allMailboxes
|
||||
.where((m) => _hasWordPrefix(m.name, ql))
|
||||
.toList()
|
||||
..sort(compareMailboxes);
|
||||
|
||||
// Collect unique addresses from address-search results where the
|
||||
@@ -306,9 +307,8 @@ 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',
|
||||
|
||||
@@ -87,18 +87,14 @@ 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,
|
||||
|
||||
@@ -125,10 +125,8 @@ 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;
|
||||
|
||||
@@ -206,9 +204,8 @@ 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;
|
||||
|
||||
|
||||
@@ -101,9 +101,8 @@ 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(
|
||||
@@ -230,9 +229,8 @@ 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 ?? ''}';
|
||||
@@ -292,9 +290,7 @@ 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,
|
||||
|
||||
@@ -33,9 +33,8 @@ String buildAboutMarkdown({
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
: '';
|
||||
final deviceModelLine = deviceModel != null
|
||||
? '| Device Model | $deviceModel |\n'
|
||||
: '';
|
||||
final deviceModelLine =
|
||||
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
||||
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
|
||||
@@ -37,17 +37,15 @@ class EmailTile extends StatelessWidget {
|
||||
final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : '';
|
||||
|
||||
return ListTile(
|
||||
leading:
|
||||
leading ??
|
||||
leading: leading ??
|
||||
Icon(
|
||||
email.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style: email.isSeen
|
||||
? null
|
||||
: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style:
|
||||
email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
|
||||
@@ -43,7 +43,9 @@ class FolderDrawer extends ConsumerWidget {
|
||||
Text(
|
||||
account?.displayName ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,8 +16,7 @@ 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'; "
|
||||
@@ -141,8 +140,7 @@ 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
|
||||
final boldStart = (parts.length >= 2
|
||||
? host.length -
|
||||
parts.last.length -
|
||||
1 -
|
||||
|
||||
@@ -16,7 +16,8 @@ Future<imap.ImapClient> _fakeImapConnect(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async => throw const SocketException('fake — no real IMAP server in tests');
|
||||
) async =>
|
||||
throw const SocketException('fake — no real IMAP server in tests');
|
||||
|
||||
void main() {
|
||||
test(
|
||||
@@ -158,7 +159,8 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
@@ -181,7 +183,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
@@ -225,7 +228,8 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@@ -243,7 +247,8 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async => '/tmp/${attachment.filename}';
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => '';
|
||||
@@ -262,7 +267,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
@@ -272,7 +278,8 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
|
||||
@@ -246,9 +246,8 @@ void main() {
|
||||
);
|
||||
|
||||
// Alice and bob each received at least msgCount messages.
|
||||
final aliceEmails = allEmails
|
||||
.where((e) => e.accountId == 'alice')
|
||||
.toList();
|
||||
final aliceEmails =
|
||||
allEmails.where((e) => e.accountId == 'alice').toList();
|
||||
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
|
||||
expect(
|
||||
aliceEmails.length,
|
||||
|
||||
@@ -346,9 +346,7 @@ void main() {
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a legacy row with no cachedAt.
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('stale text'),
|
||||
@@ -374,9 +372,7 @@ void main() {
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a row cached 8 days ago.
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('old text'),
|
||||
|
||||
@@ -107,8 +107,7 @@ void main() {
|
||||
AccountRepositoryImpl accounts,
|
||||
EmailRepositoryImpl emails,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
})
|
||||
makeRepo() {
|
||||
}) makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final emails = EmailRepositoryImpl(
|
||||
@@ -128,8 +127,7 @@ void main() {
|
||||
) async {
|
||||
await accounts.addAccount(account, userPass);
|
||||
await mailboxes.syncMailboxes('test-jmap');
|
||||
final row =
|
||||
await (db.select(db.mailboxes)
|
||||
final row = await (db.select(db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'),
|
||||
)
|
||||
@@ -272,11 +270,9 @@ void main() {
|
||||
);
|
||||
|
||||
// A sent copy should appear in the Sent mailbox.
|
||||
final sentRow =
|
||||
await (r.db.select(r.db.mailboxes)
|
||||
final sentRow = await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
@@ -284,9 +280,8 @@ void main() {
|
||||
|
||||
if (sentId != null) {
|
||||
await r.emails.syncEmails('test-jmap', sentId);
|
||||
final sentEmails = await r.emails
|
||||
.observeEmails('test-jmap', sentId)
|
||||
.first;
|
||||
final sentEmails =
|
||||
await r.emails.observeEmails('test-jmap', sentId).first;
|
||||
expect(sentEmails.any((e) => e.subject == subject), isTrue);
|
||||
} else {
|
||||
// If no Sent mailbox exists, just verify sendEmail didn't throw.
|
||||
@@ -353,8 +348,7 @@ void main() {
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
// Find a destination mailbox (Trash).
|
||||
final trashRow =
|
||||
await (r.db.select(r.db.mailboxes)
|
||||
final trashRow = await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('trash'),
|
||||
)
|
||||
|
||||
@@ -76,8 +76,7 @@ void main() {
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
})
|
||||
makeRepo() {
|
||||
}) makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
|
||||
@@ -107,9 +107,7 @@ void main() {
|
||||
'verifySyncReliability identifies extra local emails (missing on server)',
|
||||
() async {
|
||||
// 1. Manually insert a row into local DB that doesn't exist on server
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'test:999',
|
||||
accountId: 'test',
|
||||
|
||||
@@ -78,7 +78,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -113,7 +114,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@@ -138,7 +140,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
@override
|
||||
Stream<void> watchJmapPush(String a, String p) => const Stream.empty();
|
||||
@override
|
||||
@@ -153,7 +156,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
@@ -222,7 +226,8 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
|
||||
@@ -40,9 +40,7 @@ Future<String> _insertInboxEmail(
|
||||
String from = 'sender@example.com',
|
||||
String mailboxPath = 'INBOX',
|
||||
}) async {
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
@@ -59,9 +57,7 @@ Future<String> _insertInboxEmail(
|
||||
),
|
||||
);
|
||||
// Insert a thread row so _updateThread does not throw.
|
||||
await db
|
||||
.into(db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
await db.into(db.threads).insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
@@ -75,9 +71,7 @@ Future<String> _insertInboxEmail(
|
||||
|
||||
/// Creates an active Sieve script for the test account.
|
||||
Future<void> _insertSieveScript(AppDatabase db, String content) async {
|
||||
await db
|
||||
.into(db.localSieveScripts)
|
||||
.insert(
|
||||
await db.into(db.localSieveScripts).insert(
|
||||
LocalSieveScriptsCompanion.insert(
|
||||
accountId: _account.id,
|
||||
name: 'test-script',
|
||||
@@ -224,9 +218,7 @@ if header :contains "subject" ["SPAM"] {
|
||||
}
|
||||
''');
|
||||
// Insert without messageId.
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'sieve-acc:2',
|
||||
accountId: _account.id,
|
||||
@@ -236,9 +228,7 @@ if header :contains "subject" ["SPAM"] {
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
await db.into(db.threads).insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: 'sieve-acc:2',
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -59,8 +59,7 @@ void main() {
|
||||
|
||||
test('leaves HTML unchanged when there are no inline parts', () {
|
||||
// A plain text-only message.
|
||||
const plainMime =
|
||||
'MIME-Version: 1.0\r\n'
|
||||
const plainMime = 'MIME-Version: 1.0\r\n'
|
||||
'Content-Type: text/plain\r\n'
|
||||
'\r\n'
|
||||
'Hello';
|
||||
|
||||
@@ -23,8 +23,7 @@ const _jmapAccount = Account(
|
||||
jmapUrl: 'https://example.com/jmap/session',
|
||||
);
|
||||
|
||||
const _jmapSessionJson =
|
||||
'{'
|
||||
const _jmapSessionJson = '{'
|
||||
'"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}},'
|
||||
'"accounts":{},"primaryAccounts":{},"username":"alice@example.com",'
|
||||
'"apiUrl":"https://example.com/jmap/","downloadUrl":"","uploadUrl":"","state":"0"'
|
||||
@@ -117,8 +116,7 @@ void main() {
|
||||
MockClient((_) async => http.Response('', 200)),
|
||||
imapConnect: (_, __, ___) async => FakeImapClient(),
|
||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||
manageSieveConnect:
|
||||
({
|
||||
manageSieveConnect: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
@@ -144,12 +142,12 @@ void main() {
|
||||
MockClient((_) async => http.Response('', 200)),
|
||||
imapConnect: (_, __, ___) async => FakeImapClient(),
|
||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||
manageSieveConnect:
|
||||
({
|
||||
manageSieveConnect: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async => throw Exception('sieve boom'),
|
||||
}) async =>
|
||||
throw Exception('sieve boom'),
|
||||
);
|
||||
expect(
|
||||
() => svc.testConnection(accountWithSieve, 'pw'),
|
||||
|
||||
@@ -34,9 +34,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('cancelPendingChange removes an unattempted change', () async {
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -55,9 +53,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('cancelPendingChange does not remove attempted changes', () async {
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -78,9 +74,7 @@ void main() {
|
||||
|
||||
test('cancelPendingChange only removes the latest matching change', () async {
|
||||
final now = DateTime.now();
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -90,9 +84,7 @@ void main() {
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
|
||||
@@ -186,9 +186,7 @@ class _EmailRepositoryImplContract extends EmailRepositoryContract {
|
||||
bool isFlagged = false,
|
||||
DateTime? receivedAt,
|
||||
}) async {
|
||||
await _db
|
||||
.into(_db.emails)
|
||||
.insert(
|
||||
await _db.into(_db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -68,7 +68,8 @@ Map<String, dynamic> _emailGetResponse({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
int? total,
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
@@ -94,7 +95,8 @@ Map<String, dynamic> _emailChangesResponse({
|
||||
List<String> created = const [],
|
||||
List<String> updated = const [],
|
||||
List<String> destroyed = const [],
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
@@ -116,7 +118,8 @@ Map<String, dynamic> _emailChangesResponse({
|
||||
Map<String, dynamic> _emailGetOnly({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
@@ -133,7 +136,8 @@ Map<String, dynamic> _jmapEmail({
|
||||
String subject = 'Hello',
|
||||
bool seen = false,
|
||||
String? threadId,
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
'id': id,
|
||||
'mailboxIds': {mailboxId: true},
|
||||
'subject': subject,
|
||||
@@ -199,9 +203,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:42',
|
||||
accountId: 'acc-1',
|
||||
@@ -221,9 +223,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:7',
|
||||
accountId: 'acc-1',
|
||||
@@ -247,9 +247,7 @@ void main() {
|
||||
(3, DateTime(2024, 3)),
|
||||
(2, DateTime(2024, 2)),
|
||||
]) {
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:$uid',
|
||||
accountId: 'acc-1',
|
||||
@@ -276,9 +274,7 @@ void main() {
|
||||
test('getEmailBody propagates IMAP error when not cached', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -296,9 +292,7 @@ void main() {
|
||||
test('getEmailBody returns cached body without IMAP call', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -307,9 +301,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insert(
|
||||
await r.db.into(r.db.emailBodies).insert(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: 'acc-1:1',
|
||||
textBody: const Value('Hello'),
|
||||
@@ -330,9 +322,7 @@ void main() {
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
final now = DateTime.now();
|
||||
await r.db
|
||||
.into(r.db.threads)
|
||||
.insert(
|
||||
await r.db.into(r.db.threads).insert(
|
||||
ThreadsCompanion.insert(
|
||||
id: 'tid1',
|
||||
accountId: 'acc-1',
|
||||
@@ -359,9 +349,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -371,9 +359,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
@@ -384,9 +370,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final emails = await r.emails
|
||||
.observeEmailsInThread('acc-1', 'INBOX', 'tid1')
|
||||
.first;
|
||||
final emails =
|
||||
await r.emails.observeEmailsInThread('acc-1', 'INBOX', 'tid1').first;
|
||||
expect(emails, hasLength(2));
|
||||
expect(emails.map((e) => e.id).toSet(), {'acc-1:1', 'acc-1:2'});
|
||||
});
|
||||
@@ -401,9 +386,7 @@ void main() {
|
||||
'pw',
|
||||
);
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -413,9 +396,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-2:1',
|
||||
accountId: 'acc-2',
|
||||
@@ -444,9 +425,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -456,9 +435,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
@@ -486,9 +463,7 @@ void main() {
|
||||
final newer = DateTime(2024, 6);
|
||||
|
||||
// Two emails — older one has alice@, newer one has bob@.
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:old',
|
||||
accountId: 'acc-1',
|
||||
@@ -500,9 +475,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:new',
|
||||
accountId: 'acc-1',
|
||||
@@ -531,9 +504,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -559,9 +530,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -585,9 +554,7 @@ void main() {
|
||||
test('setFlag flagged=true enqueues flag_flagged change', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -610,9 +577,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -636,9 +601,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -665,9 +628,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -691,9 +652,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
// _makeRepos uses _noImapConnect which throws UnsupportedError
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'Email',
|
||||
@@ -714,9 +673,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
// Pre-seed a flag_seen at attempts=4
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: _account.id,
|
||||
resourceType: 'Email',
|
||||
@@ -748,9 +705,7 @@ void main() {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -759,9 +714,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'Email',
|
||||
@@ -793,9 +746,7 @@ void main() {
|
||||
test('snoozeEmail enqueues snooze change and updates local DB', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -823,9 +774,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
// Seed Inbox mailbox
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -836,9 +785,7 @@ void main() {
|
||||
);
|
||||
|
||||
final past = DateTime.now().subtract(const Duration(hours: 1));
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -867,7 +814,8 @@ void main() {
|
||||
http.Client mockBodyClient({
|
||||
String text = 'Hello from JMAP',
|
||||
String html = '<p>Hello from JMAP</p>',
|
||||
}) => MockClient((req) async {
|
||||
}) =>
|
||||
MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
@@ -923,9 +871,7 @@ void main() {
|
||||
test('fetches body via JMAP Email/get and caches it', () async {
|
||||
final r = _makeRepos(httpClient: mockBodyClient());
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -994,9 +940,7 @@ void main() {
|
||||
}),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1075,9 +1019,7 @@ void main() {
|
||||
}),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1107,9 +1049,7 @@ void main() {
|
||||
test('mimeTree is null when bodyStructure is absent', () async {
|
||||
final r = _makeRepos(httpClient: mockBodyClient());
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1188,9 +1128,7 @@ void main() {
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
// Pre-populate
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1200,9 +1138,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e2',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1212,9 +1148,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1241,9 +1175,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1298,9 +1230,7 @@ void main() {
|
||||
AccountRepositoryImpl accounts,
|
||||
) async {
|
||||
await accounts.addAccount(_jmapAccount, 'pw');
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1416,9 +1346,7 @@ void main() {
|
||||
String payload = '{"seen":true}',
|
||||
}) async {
|
||||
await accounts.addAccount(_jmapAccount, 'pw');
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1532,9 +1460,7 @@ void main() {
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1542,9 +1468,7 @@ void main() {
|
||||
syncedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1605,9 +1529,7 @@ void main() {
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1615,9 +1537,7 @@ void main() {
|
||||
syncedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1682,9 +1602,7 @@ void main() {
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1706,9 +1624,7 @@ void main() {
|
||||
final r = _makeRepos(httpClient: mockFlush(500));
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
// Seed a change already at attempts=4 (one below the eviction threshold)
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1813,11 +1729,9 @@ void main() {
|
||||
expect(firstCall, 'Mailbox/set');
|
||||
|
||||
// Second call should be Email/set using the newly created mailbox ID.
|
||||
final secondCallArgs =
|
||||
((capturedBodies[1]['methodCalls'] as List).first as List)[1]
|
||||
as Map<String, dynamic>;
|
||||
final update =
|
||||
(secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
||||
as List)[1] as Map<String, dynamic>;
|
||||
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||
as Map<String, dynamic>;
|
||||
expect(update['mailboxIds/mbx-snoozed'], true);
|
||||
},
|
||||
@@ -1853,7 +1767,8 @@ void main() {
|
||||
required String mailboxId,
|
||||
String? textContent,
|
||||
String? htmlContent,
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
..._jmapEmail(id: id, mailboxId: mailboxId),
|
||||
'textBody': [
|
||||
if (textContent != null) {'partId': 'text1', 'type': 'text/plain'},
|
||||
@@ -2164,9 +2079,7 @@ void main() {
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
// Seed a Sent mailbox with role='sent'
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:sentMbx',
|
||||
accountId: 'jmap-1',
|
||||
@@ -2267,9 +2180,7 @@ void main() {
|
||||
// no IMAP connection was made.
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -2278,9 +2189,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: 'acc-1:1',
|
||||
textBody: const Value('cached text'),
|
||||
@@ -2300,9 +2209,7 @@ void main() {
|
||||
test('observeFailedMutations emits only rows with lastError set', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2313,9 +2220,7 @@ void main() {
|
||||
lastError: const Value('network error'),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2338,9 +2243,7 @@ void main() {
|
||||
test('discardMutation removes the row', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
final rowId = await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
final rowId = await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2362,9 +2265,7 @@ void main() {
|
||||
test('retryMutation resets attempts and clears lastError', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
final rowId = await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
final rowId = await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2391,9 +2292,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -2412,9 +2311,8 @@ void main() {
|
||||
expect(changes, hasLength(2));
|
||||
expect(changes.map((c) => c.changeType), everyElement('move'));
|
||||
|
||||
final destinations = changes
|
||||
.map((c) => (jsonDecode(c.payload) as Map)['dest'])
|
||||
.toSet();
|
||||
final destinations =
|
||||
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
||||
expect(destinations, containsAll(['Archive', 'Trash']));
|
||||
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
@@ -2467,9 +2365,7 @@ void main() {
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Pre-seed two emails from the old server epoch (uidValidity=123).
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -2478,9 +2374,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
@@ -2492,9 +2386,7 @@ void main() {
|
||||
|
||||
// Seed an IMAP checkpoint with the old uidValidity so the code detects
|
||||
// a mismatch and triggers a full re-sync.
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'IMAP:INBOX',
|
||||
@@ -2510,8 +2402,8 @@ void main() {
|
||||
expect(remaining, isEmpty);
|
||||
|
||||
// Checkpoint must be updated to the new uidValidity.
|
||||
final stateRow =
|
||||
await (r.db.select(r.db.syncStates)..where(
|
||||
final stateRow = await (r.db.select(r.db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals('acc-1') &
|
||||
t.resourceType.equals('IMAP:INBOX'),
|
||||
@@ -2535,7 +2427,8 @@ class _FakeImapClientUidValidity extends FakeImapClient {
|
||||
String path, {
|
||||
bool enableCondStore = false,
|
||||
imap.QResyncParameters? qresync,
|
||||
}) async => imap.Mailbox(
|
||||
}) async =>
|
||||
imap.Mailbox(
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
flags: [],
|
||||
@@ -2548,7 +2441,8 @@ class _FakeImapClientUidValidity extends FakeImapClient {
|
||||
String searchCriteria = 'ALL',
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
Duration? responseTimeout,
|
||||
}) async => imap.SearchImapResult();
|
||||
}) async =>
|
||||
imap.SearchImapResult();
|
||||
}
|
||||
|
||||
// ── SSE test helper ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,7 +53,8 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
imap.StoreAction? action,
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async => imap.StoreImapResult();
|
||||
}) async =>
|
||||
imap.StoreImapResult();
|
||||
|
||||
@override
|
||||
Future<imap.GenericImapResult> uidMove(
|
||||
@@ -71,7 +72,8 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
String? fetchContentDefinition, {
|
||||
int? changedSinceModSequence,
|
||||
Duration? responseTimeout,
|
||||
}) async => const imap.FetchImapResult([], null);
|
||||
}) async =>
|
||||
const imap.FetchImapResult([], null);
|
||||
}
|
||||
|
||||
/// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService.
|
||||
|
||||
@@ -56,8 +56,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('real-world HTML email snippet', () {
|
||||
const html =
|
||||
'<p>Hello <b>Alice</b>,</p>'
|
||||
const html = '<p>Hello <b>Alice</b>,</p>'
|
||||
'<p>Please find the invoice attached.</p>'
|
||||
'<p>Best regards,<br/>Bob</p>';
|
||||
final result = htmlToPlain(html);
|
||||
|
||||
@@ -111,9 +111,7 @@ class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
|
||||
int unread = 0,
|
||||
int total = 0,
|
||||
}) async {
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insert(
|
||||
await _db.into(_db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -66,7 +66,8 @@ http.Client _mockJmap({required List<Map<String, dynamic>> apiResponses}) {
|
||||
Map<String, dynamic> _mailboxGetResponse({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
@@ -83,7 +84,8 @@ Map<String, dynamic> _mailboxChangesResponse({
|
||||
List<String> created = const [],
|
||||
List<String> updated = const [],
|
||||
List<String> destroyed = const [],
|
||||
}) => {
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
@@ -109,8 +111,7 @@ Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
})
|
||||
_makeRepos({http.Client? httpClient}) {
|
||||
}) _makeRepos({http.Client? httpClient}) {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
@@ -144,9 +145,7 @@ void main() {
|
||||
('INBOX', 'Inbox'),
|
||||
('Drafts', 'Drafts'),
|
||||
]) {
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:$path',
|
||||
accountId: 'acc-1',
|
||||
@@ -179,9 +178,7 @@ void main() {
|
||||
);
|
||||
await r.accounts.addAccount(other, 'pw2');
|
||||
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -189,9 +186,7 @@ void main() {
|
||||
name: 'Inbox',
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-2:INBOX',
|
||||
accountId: 'acc-2',
|
||||
@@ -210,9 +205,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -312,9 +305,7 @@ void main() {
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
// Pre-populate DB with existing mailboxes and state
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -324,9 +315,7 @@ void main() {
|
||||
totalCount: const Value(10),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx2',
|
||||
accountId: 'jmap-1',
|
||||
@@ -334,9 +323,7 @@ void main() {
|
||||
name: 'Sent',
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Mailbox',
|
||||
@@ -364,9 +351,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Mailbox',
|
||||
@@ -434,9 +419,7 @@ void main() {
|
||||
test('findMailboxByRole returns matching mailbox', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx-inbox',
|
||||
accountId: 'jmap-1',
|
||||
@@ -569,9 +552,7 @@ void main() {
|
||||
await accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
@@ -608,7 +589,8 @@ class _PlainArchiveImapClient extends SnoozeSpyImapClient {
|
||||
List<String>? mailboxPatterns,
|
||||
List<String>? selectionOptions,
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
}) async => [
|
||||
}) async =>
|
||||
[
|
||||
imap.Mailbox(
|
||||
encodedName: 'Archive',
|
||||
encodedPath: 'Archive',
|
||||
@@ -621,7 +603,8 @@ class _PlainArchiveImapClient extends SnoozeSpyImapClient {
|
||||
Future<imap.Mailbox> statusMailbox(
|
||||
imap.Mailbox mailbox,
|
||||
List<imap.StatusFlags> flags,
|
||||
) async => mailbox;
|
||||
) async =>
|
||||
mailbox;
|
||||
|
||||
@override
|
||||
Future<dynamic> logout() async {}
|
||||
|
||||
@@ -27,12 +27,12 @@ class _RecordingRepo implements AccountRepository {
|
||||
ManageSieveProbeService _service(_RecordingRepo repo, {required bool result}) {
|
||||
return ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async => result,
|
||||
}) async =>
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,8 +71,7 @@ void main() {
|
||||
var probeCalled = false;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
@@ -98,8 +97,7 @@ void main() {
|
||||
var probeCalled = false;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
@@ -125,8 +123,7 @@ void main() {
|
||||
bool? probedTls;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
@@ -158,8 +155,7 @@ void main() {
|
||||
String? probedHost;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
|
||||
@@ -162,9 +162,8 @@ void main() {
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames = allTriggers
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
@@ -361,9 +360,8 @@ void main() {
|
||||
final allIndexes = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
.get();
|
||||
final indexNames = allIndexes
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
final indexNames =
|
||||
allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||
expect(indexNames, contains('mailboxes_account_id'));
|
||||
expect(indexNames, contains('threads_latest_date'));
|
||||
|
||||
@@ -371,9 +369,8 @@ void main() {
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames = allTriggers
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
|
||||
@@ -67,7 +67,8 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
@@ -99,7 +100,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -136,7 +138,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
||||
Stream.value([]);
|
||||
|
||||
@@ -57,7 +57,8 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
@@ -98,7 +99,8 @@ class _CountingEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -132,7 +134,8 @@ class _CountingEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
||||
Stream.value([]);
|
||||
@@ -150,7 +153,8 @@ class _CountingEmails implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
@override
|
||||
@@ -160,7 +164,8 @@ class _CountingEmails implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
@override
|
||||
|
||||
@@ -11,9 +11,7 @@ void main() {
|
||||
late final db = openTestDatabase();
|
||||
|
||||
setUpAll(() async {
|
||||
await db
|
||||
.into(db.accounts)
|
||||
.insert(
|
||||
await db.into(db.accounts).insert(
|
||||
AccountsCompanion.insert(
|
||||
id: 'acc1',
|
||||
displayName: 'Test',
|
||||
@@ -122,7 +120,8 @@ void main() {
|
||||
|
||||
final rows = await (db.select(
|
||||
db.syncLogs,
|
||||
)..where((r) => r.result.equals('error'))).get();
|
||||
)..where((r) => r.result.equals('error')))
|
||||
.get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.result, 'error');
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
|
||||
@@ -48,9 +48,7 @@ void main() {
|
||||
await accounts.addAccount(account, 'password');
|
||||
|
||||
// Setup Inbox and Trash mailboxes
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc1:INBOX',
|
||||
accountId: 'acc1',
|
||||
@@ -58,9 +56,7 @@ void main() {
|
||||
name: 'Inbox',
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc1:Trash',
|
||||
accountId: 'acc1',
|
||||
@@ -71,9 +67,7 @@ void main() {
|
||||
);
|
||||
|
||||
// Setup an email in Inbox
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc1:101',
|
||||
accountId: 'acc1',
|
||||
@@ -100,8 +94,7 @@ void main() {
|
||||
await repo.deleteEmail(emailId);
|
||||
|
||||
// Verify it moved from INBOX (locally deleted for IMAP move)
|
||||
final inInbox =
|
||||
await (db.select(db.emails)
|
||||
final inInbox = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
@@ -120,8 +113,7 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
@@ -149,9 +141,7 @@ void main() {
|
||||
await accounts.addAccount(jmapAccount, 'password');
|
||||
|
||||
// Setup Inbox and Trash mailboxes for JMAP
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap1:INBOX',
|
||||
accountId: 'jmap1',
|
||||
@@ -160,9 +150,7 @@ void main() {
|
||||
role: const Value('inbox'),
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap1:Trash',
|
||||
accountId: 'jmap1',
|
||||
@@ -173,9 +161,7 @@ void main() {
|
||||
);
|
||||
|
||||
// Setup an email in JMAP Inbox
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: emailId,
|
||||
accountId: 'jmap1',
|
||||
@@ -190,8 +176,7 @@ void main() {
|
||||
await repo.deleteEmail(emailId);
|
||||
|
||||
// Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath)
|
||||
final inTrash =
|
||||
await (db.select(db.emails)
|
||||
final inTrash = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('Trash')))
|
||||
.get();
|
||||
@@ -209,8 +194,7 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
@@ -250,8 +234,7 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 4. Verify local state
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
@@ -290,9 +273,7 @@ void main() {
|
||||
// 2. Simulate IMAP sync: the server assigned a new UID (205) in Trash.
|
||||
// The old row (acc1:101) is removed and a new row (acc1:205) is inserted.
|
||||
await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go();
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc1:205',
|
||||
accountId: 'acc1',
|
||||
@@ -325,7 +306,8 @@ void main() {
|
||||
// 4. Verify the current email row is now in INBOX.
|
||||
final inInbox = await (db.select(
|
||||
db.emails,
|
||||
)..where((t) => t.mailboxPath.equals('INBOX'))).get();
|
||||
)..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(
|
||||
inInbox,
|
||||
isNotEmpty,
|
||||
|
||||
@@ -37,8 +37,7 @@ class ThrowingUrlLauncher extends Mock
|
||||
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message:
|
||||
'Unable to establish connection on channel: '
|
||||
message: 'Unable to establish connection on channel: '
|
||||
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +227,8 @@ void main() {
|
||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows discrepancy details when sync health has discrepancies', (
|
||||
testWidgets('shows discrepancy details when sync health has discrepancies',
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
const summary =
|
||||
|
||||
@@ -41,7 +41,8 @@ class _FakeFile extends Fake implements File {
|
||||
FileMode mode = FileMode.write,
|
||||
Encoding encoding = utf8,
|
||||
bool flush = false,
|
||||
}) async => this;
|
||||
}) async =>
|
||||
this;
|
||||
}
|
||||
|
||||
// Shared overrides for email detail tests.
|
||||
|
||||
@@ -15,7 +15,8 @@ Email _email({
|
||||
String subject = 'Hello world',
|
||||
bool isSeen = true,
|
||||
bool isFlagged = false,
|
||||
}) => Email(
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
@@ -35,7 +36,8 @@ List<Override> _overrides({
|
||||
List<Email> emails = const [],
|
||||
List<Email> searchResults = const [],
|
||||
String? syncError,
|
||||
}) => [
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
|
||||
@@ -27,7 +27,8 @@ class _MutableFakeEmailRepository extends FakeEmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async => _results;
|
||||
) async =>
|
||||
_results;
|
||||
}
|
||||
|
||||
final _kDate = DateTime(2024, 6);
|
||||
|
||||
+30
-15
@@ -137,7 +137,8 @@ class FakeDraftRepository implements DraftRepository {
|
||||
final matches = _drafts.values.where((d) {
|
||||
if (replyToEmailId == null) return d.replyToEmailId == null;
|
||||
return d.replyToEmailId == replyToEmailId;
|
||||
}).toList()..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
}).toList()
|
||||
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
@@ -216,14 +217,16 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) => Stream.value(List.of(_emails));
|
||||
}) =>
|
||||
Stream.value(List.of(_emails));
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) => observeEmails(accountId, mailboxPath).map((emails) {
|
||||
}) =>
|
||||
observeEmails(accountId, mailboxPath).map((emails) {
|
||||
return emails.map((e) {
|
||||
return EmailThread(
|
||||
threadId: e.threadId ?? e.id,
|
||||
@@ -247,7 +250,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) => Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
) =>
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
@@ -259,7 +263,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<SyncEmailsResult> syncEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => SyncEmailsResult.zero;
|
||||
) async =>
|
||||
SyncEmailsResult.zero;
|
||||
|
||||
@override
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
||||
@@ -285,7 +290,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String emailId) async => null;
|
||||
@@ -303,7 +309,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async => '/tmp/${attachment.filename}';
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => _rawRfc822;
|
||||
@@ -313,26 +320,30 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async => _searchResults;
|
||||
) async =>
|
||||
_searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
String query,
|
||||
) async => _searchResults;
|
||||
) async =>
|
||||
_searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
String address,
|
||||
) async => [];
|
||||
) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
String? accountId,
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
@@ -342,7 +353,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
@@ -541,11 +553,13 @@ List<Override> baseOverrides({
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
SyncHealthRow? syncHealth,
|
||||
}) => [
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
accountDiscoveryServiceProvider.overrideWithValue(
|
||||
@@ -590,7 +604,8 @@ Email testEmail({
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? listUnsubscribeHeader,
|
||||
}) => Email(
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
|
||||
@@ -44,7 +44,8 @@ void main() {
|
||||
_expectLightMode(html);
|
||||
});
|
||||
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off', () {
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
||||
() {
|
||||
final html = buildEmailHtml(
|
||||
'<table width="600"><tr><td>x</td></tr></table>',
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ Email _threadEmail({
|
||||
String id = 'acc-1:10',
|
||||
bool isFlagged = false,
|
||||
bool isSeen = true,
|
||||
}) => Email(
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
|
||||
@@ -88,8 +88,7 @@ void main() {
|
||||
await tester.tap(find.text('Top').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo =
|
||||
ProviderScope.containerOf(
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
@@ -111,8 +110,7 @@ void main() {
|
||||
await tester.tap(find.text('Top').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo =
|
||||
ProviderScope.containerOf(
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
@@ -175,8 +173,7 @@ void main() {
|
||||
await tester.tap(find.text('Return to mailbox'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo =
|
||||
ProviderScope.containerOf(
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
Reference in New Issue
Block a user