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