setup nix in CI, and reformat.

This commit is contained in:
Thomas SharedInbox
2026-05-12 21:55:06 +02:00
parent 8272b75b34
commit efdcab74d7
69 changed files with 1417 additions and 1434 deletions
+11 -1
View File
@@ -10,10 +10,15 @@ jobs:
name: Full Project Check name: Full Project Check
# Match the label of your self-hosted runner # Match the label of your self-hosted runner
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable Nix flakes
run: |
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
- name: Run Full Check Suite - name: Run Full Check Suite
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally. # Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
# 'task check' runs analyze, unit tests, widget tests, and integration tests. # 'task check' runs analyze, unit tests, widget tests, and integration tests.
@@ -28,6 +33,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable Nix flakes
run: |
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
- name: Build Linux - name: Build Linux
# The Taskfile task 'build-linux' currently builds --debug. # The Taskfile task 'build-linux' currently builds --debug.
# You can add a 'build-linux-release' task or override it here. # You can add a 'build-linux-release' task or override it here.
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
deploy-playstore: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
runs-on: self-hosted runs-on: self-hosted
if: github.ref == 'refs/heads/main' if: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1
View File
@@ -105,3 +105,4 @@ website/.hugo_build.lock
.wget-hsts .wget-hsts
tmp/ tmp/
.claude*
+2 -2
View File
@@ -345,7 +345,7 @@ tasks:
check-fast: check-fast:
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration) desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
deps: [analyze, test, test-widget, check-hygiene] deps: [analyze, test, check-hygiene]
check-hygiene: check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked desc: Verify that no forbidden files (like home dir config) are tracked
@@ -370,7 +370,7 @@ tasks:
check: check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, test-widget, build-linux, test] deps: [analyze, build-linux, test]
cmds: cmds:
- task: _integrations - task: _integrations
- task: coverage - task: coverage
+1
View File
@@ -68,6 +68,7 @@
jq jq
sqlite sqlite
python3 # used by stalwart-dev/start to pick random ports python3 # used by stalwart-dev/start to pick random ports
tea # Gitea CLI
]); ]);
shellHook = '' shellHook = ''
+6 -6
View File
@@ -200,10 +200,7 @@ class EmailAddress {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {if (name != null) 'name': name, 'email': email};
if (name != null) 'name': name,
'email': email,
};
} }
@override @override
@@ -316,8 +313,11 @@ class SyncEmailsResult {
final int skipped; final int skipped;
final int bytesTransferred; final int bytesTransferred;
static const zero = static const zero = SyncEmailsResult(
SyncEmailsResult(fetched: 0, skipped: 0, bytesTransferred: 0); fetched: 0,
skipped: 0,
bytesTransferred: 0,
);
SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult( SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult(
fetched: fetched + other.fetched, fetched: fetched + other.fetched,
+1 -5
View File
@@ -21,11 +21,7 @@ abstract class EmailRepository {
Future<EmailBody> getEmailBody(String emailId); Future<EmailBody> getEmailBody(String emailId);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath); Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag( Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
String emailId, {
bool? seen,
bool? flagged,
});
Future<void> moveEmail(String emailId, String destMailboxPath); Future<void> moveEmail(String emailId, String destMailboxPath);
/// Deletes the email. Returns the path of the mailbox it was moved to /// Deletes the email. Returns the path of the mailbox it was moved to
@@ -113,11 +113,10 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
/// well-known endpoint nor the autoconfig XML was found. /// well-known endpoint nor the autoconfig XML was found.
Future<ImapSmtpDiscovery?> _tryMxFallback(String domain) async { Future<ImapSmtpDiscovery?> _tryMxFallback(String domain) async {
try { try {
final url = Uri.https( final url = Uri.https('dns.google', '/resolve', {
'dns.google', 'name': domain,
'/resolve', 'type': 'MX',
{'name': domain, 'type': 'MX'}, });
);
final resp = await _client.get(url).timeout(const Duration(seconds: 5)); final resp = await _client.get(url).timeout(const Duration(seconds: 5));
if (resp.statusCode != 200) return null; if (resp.statusCode != 200) return null;
@@ -23,7 +23,9 @@ Future<bool> _defaultManageSieveProbe({
); );
try { try {
await client.logout(); await client.logout();
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
return true; return true;
} catch (e) { } catch (e) {
log('ManageSieve probe failed for $host:$port$e'); log('ManageSieve probe failed for $host:$port$e');
+3 -2
View File
@@ -68,8 +68,9 @@ class UndoService extends StateNotifier<List<UndoAction>> {
final currentPath = cancelled final currentPath = cancelled
? action.sourceMailboxPath ? action.sourceMailboxPath
: (action.destinationMailboxPath ?? action.sourceMailboxPath); : (action.destinationMailboxPath ?? action.sourceMailboxPath);
await repo await repo.restoreEmails([
.restoreEmails([original.copyWith(mailboxPath: currentPath)]); original.copyWith(mailboxPath: currentPath),
]);
} }
// 3. Move it back to source. // 3. Move it back to source.
+21 -10
View File
@@ -54,8 +54,13 @@ class AccountSyncManager {
_imapConnect, _imapConnect,
_syncLog, _syncLog,
), ),
AccountType.jmap => AccountType.jmap => _JmapAccountSync(
_JmapAccountSync(account, _mailboxes, _emails, _accounts, _syncLog), account,
_mailboxes,
_emails,
_accounts,
_syncLog,
),
}; };
_active[account.id] = loop; _active[account.id] = loop;
loop.start(); loop.start();
@@ -144,8 +149,9 @@ class _AccountSync implements _SyncLoop {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
try { try {
final (_SyncStats stats, String? capturedLog) = final (_SyncStats stats, String? capturedLog) = await _runSync(
await _runSync(account.verbose); account.verbose,
);
await _syncLog.log( await _syncLog.log(
accountId: account.id, accountId: account.id,
success: true, success: true,
@@ -239,8 +245,10 @@ class _AccountSync implements _SyncLoop {
// Check for expired snoozes and move them back to Inbox before syncing. // Check for expired snoozes and move them back to Inbox before syncing.
await _emails.wakeUpEmails(account.id); await _emails.wakeUpEmails(account.id);
final pendingFlushed = final pendingFlushed = await _emails.flushPendingChanges(
await _emails.flushPendingChanges(account.id, password); account.id,
password,
);
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
final mailboxes = await _mailboxes.observeMailboxes(account.id).first; final mailboxes = await _mailboxes.observeMailboxes(account.id).first;
var emailResult = SyncEmailsResult.zero; var emailResult = SyncEmailsResult.zero;
@@ -359,8 +367,9 @@ class _JmapAccountSync implements _SyncLoop {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
try { try {
final (_SyncStats stats, String? capturedLog) = final (_SyncStats stats, String? capturedLog) = await _runSync(
await _runSync(account.verbose); account.verbose,
);
await _syncLog.log( await _syncLog.log(
accountId: account.id, accountId: account.id,
success: true, success: true,
@@ -456,8 +465,10 @@ class _JmapAccountSync implements _SyncLoop {
await _emails.wakeUpEmails(account.id); await _emails.wakeUpEmails(account.id);
// Drain outbound queue before pulling from server. // Drain outbound queue before pulling from server.
final pendingFlushed = final pendingFlushed = await _emails.flushPendingChanges(
await _emails.flushPendingChanges(account.id, password); account.id,
password,
);
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
+5 -8
View File
@@ -11,12 +11,7 @@ import 'package:sharedinbox/data/db/database.dart';
/// Periodically verifies local state against the server's "ground truth". /// Periodically verifies local state against the server's "ground truth".
/// Results are stored in the [SyncHealth] table. /// Results are stored in the [SyncHealth] table.
class ReliabilityRunner { class ReliabilityRunner {
ReliabilityRunner( ReliabilityRunner(this._db, this._accounts, this._mailboxes, this._emails);
this._db,
this._accounts,
this._mailboxes,
this._emails,
);
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -65,8 +60,10 @@ class ReliabilityRunner {
for (final mailbox in mailboxes) { for (final mailbox in mailboxes) {
if (!_running) break; if (!_running) break;
final result = final result = await _emails.verifySyncReliability(
await _emails.verifySyncReliability(accountId, mailbox.path); accountId,
mailbox.path,
);
if (!result.isHealthy) { if (!result.isHealthy) {
totalMissingLocally += result.missingLocally.length; totalMissingLocally += result.missingLocally.length;
totalMissingOnServer += result.missingOnServer.length; totalMissingOnServer += result.missingOnServer.length;
+3 -2
View File
@@ -363,8 +363,9 @@ class AppDatabase extends _$AppDatabase {
emailIdsJson: Value( emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()), jsonEncode(threadEmails.map((e) => e.id).toList()),
), ),
participantsJson: participantsJson: Value(
Value(latest.fromJson), // Good enough for migration latest.fromJson,
), // Good enough for migration
), ),
); );
} }
+4 -4
View File
@@ -79,7 +79,9 @@ class ManageSieveClient {
await sub.cancel(); await sub.cancel();
try { try {
await socket.close(); await socket.close();
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
rethrow; rethrow;
} }
} }
@@ -127,9 +129,7 @@ class ManageSieveClient {
await _writeLine('AUTHENTICATE "PLAIN" "$initial"'); await _writeLine('AUTHENTICATE "PLAIN" "$initial"');
final resp = await _readResponse(); final resp = await _readResponse();
if (resp.status != _Status.ok) { if (resp.status != _Status.ok) {
throw ManageSieveException( throw ManageSieveException('Authentication failed: ${resp.message}');
'Authentication failed: ${resp.message}',
);
} }
} }
+1 -6
View File
@@ -24,12 +24,7 @@ class TlsModeMismatchException implements Exception {
/// If [error] is a TLS handshake failure caused by a wrong-version-number /// If [error] is a TLS handshake failure caused by a wrong-version-number
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException] /// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
/// with [host]/[port] context. Otherwise rethrow [error] unchanged. /// with [host]/[port] context. Otherwise rethrow [error] unchanged.
Never rethrowAsTlsHint( Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
Object error,
StackTrace stack,
String host,
int port,
) {
if (error.toString().contains('WRONG_VERSION_NUMBER')) { if (error.toString().contains('WRONG_VERSION_NUMBER')) {
Error.throwWithStackTrace( Error.throwWithStackTrace(
TlsModeMismatchException(host, port, error), TlsModeMismatchException(host, port, error),
@@ -13,14 +13,17 @@ class AccountRepositoryImpl implements AccountRepository {
@override @override
Stream<List<model.Account>> observeAccounts() { Stream<List<model.Account>> observeAccounts() {
return _db.select(_db.accounts).watch().map( return _db
(rows) => rows.map(_toModel).toList(), .select(_db.accounts)
); .watch()
.map((rows) => rows.map(_toModel).toList());
} }
@override @override
Future<model.Account?> getAccount(String id) async { Future<model.Account?> getAccount(String id) async {
final row = await (_db.select(_db.accounts)..where((t) => t.id.equals(id))) final row = await (_db.select(
_db.accounts,
)..where((t) => t.id.equals(id)))
.getSingleOrNull(); .getSingleOrNull();
return row == null ? null : _toModel(row); return row == null ? null : _toModel(row);
} }
@@ -53,7 +56,9 @@ class AccountRepositoryImpl implements AccountRepository {
@override @override
Future<void> updateAccount(model.Account account, {String? password}) async { Future<void> updateAccount(model.Account account, {String? password}) async {
await (_db.update(_db.accounts)..where((t) => t.id.equals(account.id))) await (_db.update(
_db.accounts,
)..where((t) => t.id.equals(account.id)))
.write( .write(
AccountsCompanion( AccountsCompanion(
displayName: Value(account.displayName), displayName: Value(account.displayName),
@@ -83,7 +83,9 @@ class DraftRepositoryImpl implements DraftRepository {
@override @override
Future<SavedDraft?> getDraft(int id) async { Future<SavedDraft?> getDraft(int id) async {
final row = await (_db.select(_db.drafts)..where((t) => t.id.equals(id))) final row = await (_db.select(
_db.drafts,
)..where((t) => t.id.equals(id)))
.getSingleOrNull(); .getSingleOrNull();
return row == null ? null : _toModel(row); return row == null ? null : _toModel(row);
} }
@@ -72,8 +72,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
account_model.Account account, account_model.Account account,
String password, String password,
) async { ) async {
final client = final client = await _imapConnect(
await _imapConnect(account, _effectiveUsername(account), password); account,
_effectiveUsername(account),
password,
);
try { try {
final mailboxes = await client.listMailboxes(recursive: true); final mailboxes = await client.listMailboxes(recursive: true);
for (final mb in mailboxes) { for (final mb in mailboxes) {
@@ -83,10 +86,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
var unread = 0; var unread = 0;
var total = 0; var total = 0;
try { try {
final status = await client.statusMailbox( final status = await client.statusMailbox(mb, [
mb, imap.StatusFlags.messages,
[imap.StatusFlags.messages, imap.StatusFlags.unseen], imap.StatusFlags.unseen,
); ]);
unread = status.messagesUnseen; unread = status.messagesUnseen;
total = status.messagesExists; total = status.messagesExists;
} catch (e) { } catch (e) {
@@ -145,7 +148,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
'Mailbox/get', 'Mailbox/get',
{'accountId': jmap.accountId, 'ids': null}, {'accountId': jmap.accountId, 'ids': null},
'0', '0',
] ],
]); ]);
final result = _responseArgs(responses, 0, 'Mailbox/get'); final result = _responseArgs(responses, 0, 'Mailbox/get');
@@ -154,7 +157,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
await _upsertJmapMailboxes(accountId, mailboxes); await _upsertJmapMailboxes(accountId, mailboxes);
await _saveSyncState(accountId, 'Mailbox', newState); await _saveSyncState(accountId, 'Mailbox', newState);
log('JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState'); log(
'JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState',
);
return mailboxes.length; return mailboxes.length;
} }
@@ -169,7 +174,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
'Mailbox/changes', 'Mailbox/changes',
{'accountId': jmap.accountId, 'sinceState': sinceState}, {'accountId': jmap.accountId, 'sinceState': sinceState},
'0', '0',
] ],
]); ]);
final changes = _responseArgs(responses, 0, 'Mailbox/changes'); final changes = _responseArgs(responses, 0, 'Mailbox/changes');
@@ -186,7 +191,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
'Mailbox/get', 'Mailbox/get',
{'accountId': jmap.accountId, 'ids': toFetch}, {'accountId': jmap.accountId, 'ids': toFetch},
'1', '1',
] ],
]); ]);
final getResult = _responseArgs(getResponses, 0, 'Mailbox/get'); final getResult = _responseArgs(getResponses, 0, 'Mailbox/get');
await _upsertJmapMailboxes(accountId, getResult['list'] as List<dynamic>); await _upsertJmapMailboxes(accountId, getResult['list'] as List<dynamic>);
@@ -194,14 +199,17 @@ class MailboxRepositoryImpl implements MailboxRepository {
// Remove destroyed mailboxes // Remove destroyed mailboxes
for (final jmapId in destroyed) { for (final jmapId in destroyed) {
await (_db.delete(_db.mailboxes) await (_db.delete(
..where((t) => t.id.equals('$accountId:$jmapId'))) _db.mailboxes,
)..where((t) => t.id.equals('$accountId:$jmapId')))
.go(); .go();
} }
await _saveSyncState(accountId, 'Mailbox', newState); await _saveSyncState(accountId, 'Mailbox', newState);
log('JMAP incremental mailbox sync: +${created.length} ' log(
'~${updated.length} -${destroyed.length}, state=$newState'); 'JMAP incremental mailbox sync: +${created.length} '
'~${updated.length} -${destroyed.length}, state=$newState',
);
return toFetch.length + destroyed.length; return toFetch.length + destroyed.length;
} }
+9 -5
View File
@@ -99,7 +99,9 @@ final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
final syncHealthProvider = final syncHealthProvider =
StreamProvider.autoDispose.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(db.syncHealth)..where((t) => t.accountId.equals(accountId))) return (db.select(
db.syncHealth,
)..where((t) => t.accountId.equals(accountId)))
.watchSingleOrNull(); .watchSingleOrNull();
}); });
@@ -115,8 +117,9 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
return manager; return manager;
}); });
final accountDiscoveryServiceProvider = final accountDiscoveryServiceProvider = Provider<AccountDiscoveryService>((
Provider<AccountDiscoveryService>((ref) { ref,
) {
return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider)); return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider));
}); });
@@ -135,8 +138,9 @@ final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
); );
}); });
final manageSieveProbeServiceProvider = final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
Provider<ManageSieveProbeService>((ref) { ref,
) {
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
+2 -10
View File
@@ -33,20 +33,12 @@ void main({List<Override> overrides = const []}) async {
await initDatabasePath(); await initDatabasePath();
runApp( runApp(
ProviderScope( ProviderScope(overrides: overrides, child: const SharedInboxApp()),
overrides: overrides,
child: const SharedInboxApp(),
),
); );
}, },
(error, stack) { (error, stack) {
// Catch unhandled async errors. // Catch unhandled async errors.
runApp( runApp(CrashScreen(exception: error, stackTrace: stack));
CrashScreen(
exception: error,
stackTrace: stack,
),
);
}, },
), ),
); );
+5 -10
View File
@@ -49,9 +49,8 @@ final router = GoRouter(
), ),
GoRoute( GoRoute(
path: ':accountId/sync-log', path: ':accountId/sync-log',
builder: (ctx, state) => SyncLogScreen( builder: (ctx, state) =>
accountId: state.pathParameters['accountId']!, SyncLogScreen(accountId: state.pathParameters['accountId']!),
),
), ),
GoRoute( GoRoute(
path: ':accountId/sieve', path: ':accountId/sieve',
@@ -68,9 +67,8 @@ final router = GoRouter(
), ),
GoRoute( GoRoute(
path: ':accountId/search', path: ':accountId/search',
builder: (ctx, state) => SearchScreen( builder: (ctx, state) =>
accountId: state.pathParameters['accountId']!, SearchScreen(accountId: state.pathParameters['accountId']!),
),
), ),
GoRoute( GoRoute(
path: ':accountId/emails/by-address/:address', path: ':accountId/emails/by-address/:address',
@@ -116,10 +114,7 @@ final router = GoRouter(
), ),
], ],
), ),
GoRoute( GoRoute(path: '/search', builder: (ctx, state) => const SearchScreen()),
path: '/search',
builder: (ctx, state) => const SearchScreen(),
),
GoRoute( GoRoute(
path: '/compose', path: '/compose',
builder: (ctx, state) { builder: (ctx, state) {
+6 -6
View File
@@ -188,9 +188,9 @@ class _AccountTile extends ConsumerWidget {
break; break;
case _AccountAction.verifySync: case _AccountAction.verifySync:
unawaited( unawaited(
ProviderScope.containerOf(context) ProviderScope.containerOf(
.read(reliabilityRunnerProvider) context,
.checkNow(), ).read(reliabilityRunnerProvider).checkNow(),
); );
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -225,9 +225,9 @@ class _AccountTile extends ConsumerWidget {
), ),
); );
if ((confirmed ?? false) && context.mounted) { if ((confirmed ?? false) && context.mounted) {
await ProviderScope.containerOf(context) await ProviderScope.containerOf(
.read(accountRepositoryProvider) context,
.removeAccount(account.id); ).read(accountRepositoryProvider).removeAccount(account.id);
} }
} }
} }
+4 -15
View File
@@ -235,9 +235,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
.addAccount(accountToSave, _passwordCtrl.text); .addAccount(accountToSave, _passwordCtrl.text);
// Probe ManageSieve in the background; the menu starts visible (null) // Probe ManageSieve in the background; the menu starts visible (null)
// and disappears on probe failure via the observeAccounts stream. // and disappears on probe failure via the observeAccounts stream.
unawaited( unawaited(ref.read(manageSieveProbeServiceProvider).probe(accountToSave));
ref.read(manageSieveProbeServiceProvider).probe(accountToSave),
);
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -384,10 +382,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
onPressed: () => _tryConnection(_jmapFormKey, _buildJmapAccount), onPressed: () => _tryConnection(_jmapFormKey, _buildJmapAccount),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton( FilledButton(onPressed: _saveJmap, child: const Text('Save')),
onPressed: _saveJmap,
child: const Text('Save'),
),
], ],
), ),
), ),
@@ -439,10 +434,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
onPressed: () => _tryConnection(_imapFormKey, _buildImapAccount), onPressed: () => _tryConnection(_imapFormKey, _buildImapAccount),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton( FilledButton(onPressed: _saveImap, child: const Text('Save')),
onPressed: _saveImap,
child: const Text('Save'),
),
], ],
), ),
), ),
@@ -461,10 +453,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
_emailCtrl.text.trim(), _emailCtrl.text.trim(),
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
Text( Text(accountTypeLabel, style: Theme.of(context).textTheme.bodySmall),
accountTypeLabel,
style: Theme.of(context).textTheme.bodySmall,
),
], ],
), ),
); );
+2 -7
View File
@@ -11,9 +11,7 @@ class ChangeLogScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('ChangeLog')),
title: const Text('ChangeLog'),
),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: rootBundle.loadString('assets/changelog.txt'), future: rootBundle.loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -39,10 +37,7 @@ class ChangeLogScreen extends StatelessWidget {
} }
}, },
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: const TextStyle( p: const TextStyle(fontFamily: 'monospace', fontSize: 13),
fontFamily: 'monospace',
fontSize: 13,
),
), ),
); );
}, },
+13 -14
View File
@@ -190,9 +190,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to open file: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
} finally { } finally {
if (mounted) setState(() => _opening = false); if (mounted) setState(() => _opening = false);
} }
@@ -204,9 +204,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Select an account first')), context,
); ).showSnackBar(const SnackBar(content: Text('Select an account first')));
return; return;
} }
setState(() => _sending = true); setState(() => _sending = true);
@@ -229,8 +229,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
.toList(), .toList(),
subject: _subject.text, subject: _subject.text,
body: _body.text, body: _body.text,
attachmentFilePaths: attachmentFilePaths: List.unmodifiable(
List.unmodifiable(_attachments.map((a) => a.path).toList()), _attachments.map((a) => a.path).toList(),
),
); );
await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft); await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft);
// Delete the draft only after a successful send. // Delete the draft only after a successful send.
@@ -240,8 +241,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar(SnackBar(content: Text('Send failed: $e'))); context,
).showSnackBar(SnackBar(content: Text('Send failed: $e')));
} finally { } finally {
if (mounted) setState(() => _sending = false); if (mounted) setState(() => _sending = false);
} }
@@ -257,10 +259,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Center( child: Center(
child: Text( child: Text('Saved', style: TextStyle(fontSize: 12)),
'Saved',
style: TextStyle(fontSize: 12),
),
), ),
), ),
IconButton( IconButton(
+8 -10
View File
@@ -25,11 +25,7 @@ class CrashScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Icon( const Icon(Icons.error_outline, color: Colors.red, size: 64),
Icons.error_outline,
color: Colors.red,
size: 64,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'SharedInbox encountered an unexpected error and needs to be restarted.', 'SharedInbox encountered an unexpected error and needs to be restarted.',
@@ -68,8 +64,10 @@ class CrashScreen extends StatelessWidget {
), ),
child: Text( child: Text(
stackTrace.toString(), stackTrace.toString(),
style: style: const TextStyle(
const TextStyle(fontFamily: 'monospace', fontSize: 10), fontFamily: 'monospace',
fontSize: 10,
),
), ),
), ),
], ],
@@ -113,9 +111,9 @@ class CrashScreen extends StatelessWidget {
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Error: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Error: $e')));
} }
} }
}, },
+1 -3
View File
@@ -213,9 +213,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
// Re-probe when the cached availability was cleared above. // Re-probe when the cached availability was cleared above.
if (updated.type == AccountType.imap && if (updated.type == AccountType.imap &&
updated.manageSieveAvailable == null) { updated.manageSieveAvailable == null) {
unawaited( unawaited(ref.read(manageSieveProbeServiceProvider).probe(updated));
ref.read(manageSieveProbeServiceProvider).probe(updated),
);
} }
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
+10 -16
View File
@@ -169,10 +169,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (header != null) ...[ if (header != null) ...[_buildHeader(ctx, header), const Divider()],
_buildHeader(ctx, header),
const Divider(),
],
if (hasHtml) ...[ if (hasHtml) ...[
if (!_loadRemoteImages) if (!_loadRemoteImages)
Align( Align(
@@ -188,9 +185,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
Html( Html(
data: body.htmlBody!, data: body.htmlBody!,
extensions: [ extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
],
), ),
] else ] else
SelectableText( SelectableText(
@@ -238,9 +233,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Opening file failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Opening file failed: $e')));
} finally { } finally {
if (mounted) setState(() => _downloading.remove(att.filename)); if (mounted) setState(() => _downloading.remove(att.filename));
} }
@@ -430,8 +425,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
color: i.isEven color: i.isEven
? Theme.of(ctx).colorScheme.surfaceContainerHighest ? Theme.of(ctx).colorScheme.surfaceContainerHighest
: Theme.of(ctx).colorScheme.surface, : Theme.of(ctx).colorScheme.surface,
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(vertical: 4, horizontal: 8), vertical: 4,
horizontal: 8,
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -442,10 +439,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(flex: 2, child: SelectableText(header.value)),
flex: 2,
child: SelectableText(header.value),
),
], ],
), ),
); );
+10 -15
View File
@@ -104,11 +104,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
setState(() => _searchLoading = true); setState(() => _searchLoading = true);
try { try {
final results = await ref.read(emailRepositoryProvider).searchEmails( final results = await ref
widget.accountId, .read(emailRepositoryProvider)
widget.mailboxPath, .searchEmails(widget.accountId, widget.mailboxPath, query.trim());
query.trim(),
);
if (mounted) setState(() => _searchResults = results); if (mounted) setState(() => _searchResults = results);
} finally { } finally {
if (mounted) setState(() => _searchLoading = false); if (mounted) setState(() => _searchLoading = false);
@@ -182,9 +180,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Sync failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
} }
}, },
), ),
@@ -288,10 +286,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
if (threads.isEmpty) { if (threads.isEmpty) {
return ListView( return ListView(
children: const [ children: const [
SizedBox( SizedBox(height: 300, child: Center(child: Text('No emails'))),
height: 300,
child: Center(child: Text('No emails')),
),
], ],
); );
} }
@@ -309,9 +304,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
.findMailboxByRole(widget.accountId, role); .findMailboxByRole(widget.accountId, role);
if (!mounted) return; if (!mounted) return;
if (mailbox == null) { if (mailbox == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(notFoundMessage)), context,
); ).showSnackBar(SnackBar(content: Text(notFoundMessage)));
return; return;
} }
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
+6 -7
View File
@@ -78,8 +78,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
.where( .where(
(e) => (e) =>
e.accountId == accId && e.accountId == accId &&
[...e.from, ...e.to, ...e.cc] [
.any((a) => a.email == addrEmail), ...e.from,
...e.to,
...e.cc,
].any((a) => a.email == addrEmail),
) )
.length; .length;
addresses.add((addr, count, accId)); addresses.add((addr, count, accId));
@@ -147,11 +150,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.addresses.isNotEmpty) ...[ if (r.addresses.isNotEmpty) ...[
const _SectionHeader('Addresses'), const _SectionHeader('Addresses'),
for (final (addr, count, accId) in r.addresses) for (final (addr, count, accId) in r.addresses)
_AddressTile( _AddressTile(addr: addr, count: count, accountId: accId),
addr: addr,
count: count,
accountId: accId,
),
], ],
if (r.emails.isNotEmpty) ...[ if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'), const _SectionHeader('Messages'),
+3 -4
View File
@@ -50,10 +50,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
Future<void> _loadContent() async { Future<void> _loadContent() async {
setState(() => _loadingContent = true); setState(() => _loadingContent = true);
try { try {
final content = await ref.read(sieveRepositoryProvider).getScriptContent( final content = await ref
widget.accountId, .read(sieveRepositoryProvider)
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);
+10 -21
View File
@@ -59,9 +59,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
await _load(); await _load();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to activate: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
} }
} }
} }
@@ -92,9 +92,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
await _load(); await _load();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to delete: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
} }
} }
} }
@@ -106,9 +106,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () async { onPressed: () async {
await context.push( await context.push('/accounts/${widget.accountId}/sieve/edit');
'/accounts/${widget.accountId}/sieve/edit',
);
await _load(); await _load();
}, },
child: const Icon(Icons.add), child: const Icon(Icons.add),
@@ -129,10 +127,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
children: [ children: [
Text(_error!, style: const TextStyle(color: Colors.red)), Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton( FilledButton(onPressed: _load, child: const Text('Retry')),
onPressed: _load,
child: const Text('Retry'),
),
], ],
), ),
), ),
@@ -200,10 +195,7 @@ class _ScriptTile extends StatelessWidget {
} }
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
const PopupMenuItem( const PopupMenuItem(value: _ScriptAction.edit, child: Text('Edit')),
value: _ScriptAction.edit,
child: Text('Edit'),
),
if (!script.isActive) if (!script.isActive)
const PopupMenuItem( const PopupMenuItem(
value: _ScriptAction.activate, value: _ScriptAction.activate,
@@ -217,10 +209,7 @@ class _ScriptTile extends StatelessWidget {
], ],
), ),
onTap: () async { onTap: () async {
await context.push( await context.push('/accounts/$accountId/sieve/edit', extra: script);
'/accounts/$accountId/sieve/edit',
extra: script,
);
onEdited(); onEdited();
}, },
); );
+2 -7
View File
@@ -125,10 +125,7 @@ class _SyncLogTile extends StatelessWidget {
entry.isOk entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel' ? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel', : 'Error · took $durationLabel',
style: TextStyle( style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
fontSize: 12,
color: entry.isOk ? null : errorColor,
),
), ),
children: [ children: [
Padding( Padding(
@@ -211,9 +208,7 @@ class _SyncLogTile extends StatelessWidget {
style: const TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(fontSize: 12, color: Colors.grey),
), ),
), ),
Expanded( Expanded(child: Text(value, style: const TextStyle(fontSize: 12))),
child: Text(value, style: const TextStyle(fontSize: 12)),
),
], ],
), ),
); );
+1 -3
View File
@@ -30,9 +30,7 @@ class ThreadDetailScreen extends ConsumerWidget {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Thread')),
title: const Text('Thread'),
),
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
+3 -3
View File
@@ -81,9 +81,9 @@ class _UndoActionTile extends ConsumerWidget {
.read(undoServiceProvider.notifier) .read(undoServiceProvider.notifier)
.undo(actionId: action.id); .undo(actionId: action.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('Action undone.')), context,
); ).showSnackBar(const SnackBar(content: Text('Action undone.')));
} }
}, },
child: const Text('Undo'), child: const Text('Undo'),
-26
View File
@@ -1,26 +0,0 @@
# Dockerfile for a Codeberg Runner with Nix and a non-root worker
FROM gitea/act_runner:latest-ubuntu
# Install Nix requirements and basic tools
RUN apt-get update && apt-get install -y curl xz-utils sudo && rm -rf /var/lib/apt/lists/*
# Install Nix in single-user mode
RUN curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux \
--init none \
--no-confirm
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
# Create a restricted 'worker' user for running the actual CI jobs
RUN useradd -m -s /bin/bash worker && \
mkdir -p /home/worker && \
chown -R worker:worker /home/worker
# Allow the worker user to use Nix
RUN chown -R worker:worker /nix/var/nix/profiles/per-user/worker || true && \
chmod -R 777 /nix/store /nix/var/nix/db
# We still start as root so the act_runner entrypoint can initialize,
# but the 'act_runner' is configured to run jobs as a specific user if requested.
# However, by default, act_runner executes inside this container.
-39
View File
@@ -1,39 +0,0 @@
# SharedInbox CI Runner
This directory contains the configuration for a self-hosted Codeberg (Forgejo) Actions runner.
## Strategy: "Thin CI, Heavy Taskfile"
We use a self-hosted runner to bypass the resource limits of hosted CI. The CI workflow is a thin wrapper that invokes `nix develop --command task check`. This ensures that CI environments are identical to local development environments.
## Prerequisites
- Docker and Docker Compose installed.
- Systemd (for persistence).
- A registration token from Codeberg (Settings > Actions > Runners).
- A `.env` file in the project root containing `CODEBERG_CI_RUNNER_TOKEN`.
## Installation & Setup
Run these commands as a user with `sudo` to install the runner on your laptop or VPS:
```bash
# 1. Create the system directory
sudo mkdir -p /opt/sharedinbox-runner
# 2. Copy the runner configuration and your .env file
# (Run this from the root of your local sharedinbox3 project)
sudo cp -r sharedinbox-runner /opt/sharedinbox-runner/
sudo cp .env /opt/sharedinbox-runner/
# 3. Install the systemd service
sudo cp /opt/sharedinbox-runner/sharedinbox-runner/sharedinbox-runner.service /etc/systemd/system/
# 4. Reload systemd and start the service
sudo systemctl daemon-reload
sudo systemctl enable --now sharedinbox-runner.service
```
## Management
- **Check status:** `systemctl status sharedinbox-runner.service`
- **View logs:** `journalctl -u sharedinbox-runner.service -f`
- **Restart:** `sudo systemctl restart sharedinbox-runner.service`
- **Stop:** `sudo systemctl stop sharedinbox-runner.service`
-19
View File
@@ -1,19 +0,0 @@
services:
runner:
build:
context: .
dockerfile: Dockerfile
restart: always
env_file:
- ../.env
environment:
- GITEA_INSTANCE_URL=${CODEBERG_INSTANCE_URL:-https://codeberg.org}
- GITEA_RUNNER_REGISTRATION_TOKEN=${CODEBERG_CI_RUNNER_TOKEN}
- GITEA_RUNNER_NAME=${CODEBERG_RUNNER_NAME:-laptop-runner}
- GITEA_RUNNER_LABELS=${CODEBERG_RUNNER_LABELS:-self-hosted,linux,nix}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./runner-data:/data
# Use host network if you want to access local services easily,
# but for most cases the default bridge is fine.
# network_mode: host
@@ -1,18 +0,0 @@
[Unit]
Description=SharedInbox CI Runner (Docker Compose)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/
ExecStartPre=-/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml down
ExecStart=/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml up --build
ExecStop=/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml down
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
+130 -126
View File
@@ -126,144 +126,148 @@ void main() {
await db.close(); await db.close();
}); });
test('concurrent IMAP + JMAP sync caches all emails without errors', test(
timeout: const Timeout(Duration(seconds: 30)), () async { 'concurrent IMAP + JMAP sync caches all emails without errors',
final ts = DateTime.now().millisecondsSinceEpoch; timeout: const Timeout(Duration(seconds: 30)),
const msgCount = 2; () async {
final ts = DateTime.now().millisecondsSinceEpoch;
const msgCount = 2;
// ── 1. Send emails in both directions ───────────────────────────────────── // ── 1. Send emails in both directions ─────────────────────────────────────
// alice → bob (alice uses IMAP; bob uses JMAP) // alice → bob (alice uses IMAP; bob uses JMAP)
// bob → alice (cross-direction) // bob → alice (cross-direction)
for (var i = 0; i < msgCount; i++) { for (var i = 0; i < msgCount; i++) {
await _sendMessage( await _sendMessage(
host: imapHost, host: imapHost,
port: smtpPort, port: smtpPort,
from: aliceUser, from: aliceUser,
pass: alicePass, pass: alicePass,
to: bobUser, to: bobUser,
subject: 'alice-to-bob-$ts-$i', subject: 'alice-to-bob-$ts-$i',
);
await _sendMessage(
host: imapHost,
port: smtpPort,
from: bobUser,
pass: bobPass,
to: aliceUser,
subject: 'bob-to-alice-$ts-$i',
);
}
// Give Stalwart a moment to deliver all messages.
await Future<void>.delayed(const Duration(milliseconds: 800));
// ── 2. Insert accounts ─────────────────────────────────────────────────────
final aliceAccount = model.Account(
id: 'alice',
displayName: 'Alice',
email: aliceUser,
imapHost: imapHost,
imapPort: imapPort,
imapSsl: false,
smtpHost: imapHost,
smtpPort: smtpPort,
); );
await _sendMessage( final bobAccount = model.Account(
host: imapHost, id: 'bob',
port: smtpPort, displayName: 'Bob',
from: bobUser, email: bobUser,
pass: bobPass, type: model.AccountType.jmap,
to: aliceUser, jmapUrl: '$jmapUrl/.well-known/jmap',
subject: 'bob-to-alice-$ts-$i', smtpHost: imapHost,
smtpPort: smtpPort,
); );
}
// Give Stalwart a moment to deliver all messages.
await Future<void>.delayed(const Duration(milliseconds: 800));
// ── 2. Insert accounts ───────────────────────────────────────────────────── await accounts.addAccount(aliceAccount, alicePass);
final aliceAccount = model.Account( await accounts.addAccount(bobAccount, bobPass);
id: 'alice',
displayName: 'Alice',
email: aliceUser,
imapHost: imapHost,
imapPort: imapPort,
imapSsl: false,
smtpHost: imapHost,
smtpPort: smtpPort,
);
final bobAccount = model.Account(
id: 'bob',
displayName: 'Bob',
email: bobUser,
type: model.AccountType.jmap,
jmapUrl: '$jmapUrl/.well-known/jmap',
smtpHost: imapHost,
smtpPort: smtpPort,
);
await accounts.addAccount(aliceAccount, alicePass); final httpClient = http.Client();
await accounts.addAccount(bobAccount, bobPass); addTearDown(httpClient.close);
final httpClient = http.Client(); final mailboxRepo = MailboxRepositoryImpl(
addTearDown(httpClient.close); db,
accounts,
imapConnect: _connectImapPlaintext,
httpClient: httpClient,
);
final emailRepo = EmailRepositoryImpl(
db,
accounts,
imapConnect: _connectImapPlaintext,
httpClient: httpClient,
);
final mailboxRepo = MailboxRepositoryImpl( // ── 3. Sync mailboxes concurrently ─────────────────────────────────────────
db,
accounts,
imapConnect: _connectImapPlaintext,
httpClient: httpClient,
);
final emailRepo = EmailRepositoryImpl(
db,
accounts,
imapConnect: _connectImapPlaintext,
httpClient: httpClient,
);
// ── 3. Sync mailboxes concurrently ─────────────────────────────────────────
await Future.wait([
mailboxRepo.syncMailboxes('alice'),
mailboxRepo.syncMailboxes('bob'),
]);
final allMailboxes = await db.select(db.mailboxes).get();
expect(
allMailboxes,
isNotEmpty,
reason: 'mailboxes should be cached after sync',
);
// Grab INBOX paths for each account.
// IMAP: path is the mailbox path string (e.g. "INBOX").
// JMAP: path is the server-assigned JMAP mailbox ID; match by role or name.
final aliceInbox = allMailboxes
.firstWhere(
(m) => m.accountId == 'alice' && m.path.toUpperCase() == 'INBOX',
)
.path;
final bobInbox = allMailboxes
.firstWhere(
(m) =>
m.accountId == 'bob' &&
(m.role == 'inbox' || m.name.toLowerCase() == 'inbox'),
)
.path;
// ── 4. Sync emails concurrently — run twice to exercise incremental sync ───
for (var round = 0; round < 2; round++) {
await Future.wait([ await Future.wait([
emailRepo.syncEmails('alice', aliceInbox), mailboxRepo.syncMailboxes('alice'),
emailRepo.syncEmails('bob', bobInbox), mailboxRepo.syncMailboxes('bob'),
]); ]);
}
// ── 5. Verify DB consistency ─────────────────────────────────────────────── final allMailboxes = await db.select(db.mailboxes).get();
final allEmails = await db.select(db.emails).get(); expect(
allMailboxes,
isNotEmpty,
reason: 'mailboxes should be cached after sync',
);
// No duplicate email IDs. // Grab INBOX paths for each account.
final ids = allEmails.map((e) => e.id).toList(); // IMAP: path is the mailbox path string (e.g. "INBOX").
expect( // JMAP: path is the server-assigned JMAP mailbox ID; match by role or name.
ids.toSet().length, final aliceInbox = allMailboxes
equals(ids.length), .firstWhere(
reason: 'duplicate email IDs in DB', (m) => m.accountId == 'alice' && m.path.toUpperCase() == 'INBOX',
); )
.path;
final bobInbox = allMailboxes
.firstWhere(
(m) =>
m.accountId == 'bob' &&
(m.role == 'inbox' || m.name.toLowerCase() == 'inbox'),
)
.path;
// Alice and bob each received at least msgCount messages. // ── 4. Sync emails concurrently — run twice to exercise incremental sync ───
final aliceEmails = allEmails.where((e) => e.accountId == 'alice').toList(); for (var round = 0; round < 2; round++) {
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList(); await Future.wait([
expect( emailRepo.syncEmails('alice', aliceInbox),
aliceEmails.length, emailRepo.syncEmails('bob', bobInbox),
greaterThanOrEqualTo(msgCount), ]);
reason: "alice's inbox should contain synced emails", }
);
expect(
bobEmails.length,
greaterThanOrEqualTo(msgCount),
reason: "bob's inbox should contain synced emails",
);
// All rows have a non-empty account ID. // ── 5. Verify DB consistency ───────────────────────────────────────────────
for (final e in allEmails) { final allEmails = await db.select(db.emails).get();
expect(e.accountId, isNotEmpty);
}
// No pending changes left in the queue. // No duplicate email IDs.
final pending = await db.select(db.pendingChanges).get(); final ids = allEmails.map((e) => e.id).toList();
expect(pending, isEmpty, reason: 'no outbound mutations expected'); expect(
}); ids.toSet().length,
equals(ids.length),
reason: 'duplicate email IDs in DB',
);
// Alice and bob each received at least msgCount messages.
final aliceEmails =
allEmails.where((e) => e.accountId == 'alice').toList();
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
expect(
aliceEmails.length,
greaterThanOrEqualTo(msgCount),
reason: "alice's inbox should contain synced emails",
);
expect(
bobEmails.length,
greaterThanOrEqualTo(msgCount),
reason: "bob's inbox should contain synced emails",
);
// All rows have a non-empty account ID.
for (final e in allEmails) {
expect(e.accountId, isNotEmpty);
}
// No pending changes left in the queue.
final pending = await db.select(db.pendingChanges).get();
expect(pending, isEmpty, reason: 'no outbound mutations expected');
},
);
} }
@@ -30,8 +30,9 @@ Future<ImapClient> _imapConnect({
required String user, required String user,
required String pass, required String pass,
}) async { }) async {
final client = final client = ImapClient(
ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); defaultResponseTimeout: const Duration(seconds: 20),
);
await client.connectToServer(host, port, isSecure: false); await client.connectToServer(host, port, isSecure: false);
await client.login(user, pass); await client.login(user, pass);
return client; return client;
@@ -113,8 +114,9 @@ void main() {
String username, String username,
String password, String password,
) async { ) async {
final client = final client = ImapClient(
ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); defaultResponseTimeout: const Duration(seconds: 20),
);
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false); await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
await client.login(username, password); await client.login(username, password);
return client; return client;
@@ -200,44 +202,47 @@ void main() {
}); });
test( test(
'syncEmails incremental sync fetches only messages newer than checkpoint', 'syncEmails incremental sync fetches only messages newer than checkpoint',
() async { () async {
await appendToInbox('first'); await appendToInbox('first');
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final afterFirst = await r.emails.observeEmails('test', 'INBOX').first; final afterFirst = await r.emails.observeEmails('test', 'INBOX').first;
expect(afterFirst, hasLength(1)); expect(afterFirst, hasLength(1));
expect(afterFirst.first.subject, 'first'); expect(afterFirst.first.subject, 'first');
await appendToInbox('second'); await appendToInbox('second');
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final afterSecond = await r.emails.observeEmails('test', 'INBOX').first; final afterSecond = await r.emails.observeEmails('test', 'INBOX').first;
expect(afterSecond, hasLength(2)); expect(afterSecond, hasLength(2));
expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'}); expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'});
}); },
);
test('CONDSTORE fast-path: second sync skips fetch when nothing changed', test(
() async { 'CONDSTORE fast-path: second sync skips fetch when nothing changed',
await appendToInbox('condstore-test'); () async {
await appendToInbox('condstore-test');
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
// First sync — full sync, saves modseq checkpoint. // First sync — full sync, saves modseq checkpoint.
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final stateAfterFirst = await r.db.select(r.db.syncStates).get(); final stateAfterFirst = await r.db.select(r.db.syncStates).get();
expect(stateAfterFirst, hasLength(1)); expect(stateAfterFirst, hasLength(1));
// Second sync with no server changes — CONDSTORE fast-path should skip // Second sync with no server changes — CONDSTORE fast-path should skip
// fetching. DB email count must stay the same. // fetching. DB email count must stay the same.
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first; final emails = await r.emails.observeEmails('test', 'INBOX').first;
expect(emails, hasLength(1)); expect(emails, hasLength(1));
}); },
);
test('CONDSTORE flag refresh updates flags in local DB', () async { test('CONDSTORE flag refresh updates flags in local DB', () async {
await appendToInbox('flag-refresh-test'); await appendToInbox('flag-refresh-test');
@@ -258,10 +263,7 @@ void main() {
); );
try { try {
await imap.selectMailboxByPath('INBOX'); await imap.selectMailboxByPath('INBOX');
final seq = MessageSequence.fromIds( final seq = MessageSequence.fromIds([emails.first.uid], isUid: true);
[emails.first.uid],
isUid: true,
);
await imap.uidMarkSeen(seq); await imap.uidMarkSeen(seq);
} finally { } finally {
await imap.logout(); await imap.logout();
@@ -330,53 +332,57 @@ void main() {
expect(cached.textBody, body.textBody); expect(cached.textBody, body.textBody);
}); });
test('blob expiry: re-fetches body when cachedAt is null (legacy row)', test(
() async { 'blob expiry: re-fetches body when cachedAt is null (legacy row)',
await appendToInbox('legacy-body-test', body: 'Fresh from server'); () async {
await appendToInbox('legacy-body-test', body: 'Fresh from server');
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first; final emails = await r.emails.observeEmails('test', 'INBOX').first;
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.into(r.db.emailBodies).insertOnConflictUpdate( await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert( EmailBodiesCompanion.insert(
emailId: emailId, emailId: emailId,
textBody: const Value('stale text'), textBody: const Value('stale text'),
cachedAt: const Value(null), cachedAt: const Value(null),
), ),
); );
final body = await r.emails.getEmailBody(emailId); final body = await r.emails.getEmailBody(emailId);
expect(body.textBody, contains('Fresh from server')); expect(body.textBody, contains('Fresh from server'));
}); },
);
test('blob expiry: re-fetches body when cachedAt is older than 7 days', test(
() async { 'blob expiry: re-fetches body when cachedAt is older than 7 days',
await appendToInbox('old-body-test', body: 'Current content'); () async {
await appendToInbox('old-body-test', body: 'Current content');
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first; final emails = await r.emails.observeEmails('test', 'INBOX').first;
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.into(r.db.emailBodies).insertOnConflictUpdate( await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert( EmailBodiesCompanion.insert(
emailId: emailId, emailId: emailId,
textBody: const Value('old text'), textBody: const Value('old text'),
cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))), cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))),
), ),
); );
final body = await r.emails.getEmailBody(emailId); final body = await r.emails.getEmailBody(emailId);
expect(body.textBody, contains('Current content')); expect(body.textBody, contains('Current content'));
}); },
);
test('sendEmail delivers via SMTP and appends copy to Sent folder', () async { test('sendEmail delivers via SMTP and appends copy to Sent folder', () async {
final subject = 'send-${DateTime.now().millisecondsSinceEpoch}'; final subject = 'send-${DateTime.now().millisecondsSinceEpoch}';
@@ -426,8 +432,11 @@ void main() {
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
final results = final results = await r.emails.searchEmails(
await r.emails.searchEmails('test', 'INBOX', 'xyzzy-no-match'); 'test',
'INBOX',
'xyzzy-no-match',
);
expect(results, isEmpty); expect(results, isEmpty);
}); });
@@ -31,8 +31,9 @@ Future<ImapClient> _imapConnect({
required String user, required String user,
required String pass, required String pass,
}) async { }) async {
final client = final client = ImapClient(
ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); defaultResponseTimeout: const Duration(seconds: 20),
);
await client.connectToServer(host, port, isSecure: false); await client.connectToServer(host, port, isSecure: false);
await client.login(user, pass); await client.login(user, pass);
return client; return client;
@@ -172,24 +173,26 @@ void main() {
expect(emails.first.isSeen, isFalse); expect(emails.first.isSeen, isFalse);
}); });
test('syncEmails saves state and incremental sync picks up new messages', test(
() async { 'syncEmails saves state and incremental sync picks up new messages',
final r = makeRepo(); () async {
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes); final r = makeRepo();
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
await appendToInbox('first'); await appendToInbox('first');
await r.emails.syncEmails('test-jmap', inboxId); await r.emails.syncEmails('test-jmap', inboxId);
expect( expect(
await r.emails.observeEmails('test-jmap', inboxId).first, await r.emails.observeEmails('test-jmap', inboxId).first,
hasLength(1), hasLength(1),
); );
await appendToInbox('second'); await appendToInbox('second');
await r.emails.syncEmails('test-jmap', inboxId); await r.emails.syncEmails('test-jmap', inboxId);
final emails = await r.emails.observeEmails('test-jmap', inboxId).first; final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
expect(emails, hasLength(2)); expect(emails, hasLength(2));
expect(emails.map((e) => e.subject).toSet(), {'first', 'second'}); expect(emails.map((e) => e.subject).toSet(), {'first', 'second'});
}); },
);
test('syncEmails removes email deleted on server from local DB', () async { test('syncEmails removes email deleted on server from local DB', () async {
await appendToInbox('keep'); await appendToInbox('keep');
@@ -247,42 +250,44 @@ void main() {
expect(cached.textBody, body.textBody); expect(cached.textBody, body.textBody);
}); });
test('sendEmail submits via JMAP EmailSubmission and creates Sent copy', test(
() async { 'sendEmail submits via JMAP EmailSubmission and creates Sent copy',
final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}'; () async {
final r = makeRepo(); final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}';
await r.accounts.addAccount(account, userPass); final r = makeRepo();
await r.mailboxes.syncMailboxes('test-jmap'); await r.accounts.addAccount(account, userPass);
await r.mailboxes.syncMailboxes('test-jmap');
await r.emails.sendEmail( await r.emails.sendEmail(
'test-jmap', 'test-jmap',
EmailDraft( EmailDraft(
from: EmailAddress(name: 'Alice', email: userEmail), from: EmailAddress(name: 'Alice', email: userEmail),
to: [EmailAddress(name: 'Alice', email: userEmail)], to: [EmailAddress(name: 'Alice', email: userEmail)],
cc: [], cc: [],
subject: subject, subject: subject,
body: 'Integration test message via JMAP', body: 'Integration test message via JMAP',
), ),
); );
// A sent copy should appear in the Sent mailbox. // A sent copy should appear in the Sent mailbox.
final sentRow = await (r.db.select(r.db.mailboxes) final sentRow = await (r.db.select(r.db.mailboxes)
..where( ..where(
(t) => t.accountId.equals('test-jmap') & t.role.equals('sent'), (t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
) )
..limit(1)) ..limit(1))
.getSingleOrNull(); .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 = final sentEmails =
await r.emails.observeEmails('test-jmap', sentId).first; await r.emails.observeEmails('test-jmap', sentId).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.
} }
}); },
);
test('flushPendingChanges marks email as seen on server', () async { test('flushPendingChanges marks email as seen on server', () async {
await appendToInbox('flag-test'); await appendToInbox('flag-test');
+1 -6
View File
@@ -45,12 +45,7 @@ void main() {
}); });
test('login and list mailboxes', () async { test('login and list mailboxes', () async {
final client = await _connect( final client = await _connect(userA, passA, host: imapHost, port: imapPort);
userA,
passA,
host: imapHost,
port: imapPort,
);
addTearDown(() => client.logout().ignore()); addTearDown(() => client.logout().ignore());
// listMailboxes() returns List<Mailbox> directly // listMailboxes() returns List<Mailbox> directly
@@ -26,8 +26,9 @@ Future<ImapClient> _imapConnect({
required String user, required String user,
required String pass, required String pass,
}) async { }) async {
final client = final client = ImapClient(
ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); defaultResponseTimeout: const Duration(seconds: 20),
);
await client.connectToServer(host, port, isSecure: false); await client.connectToServer(host, port, isSecure: false);
await client.login(user, pass); await client.login(user, pass);
return client; return client;
@@ -63,8 +64,9 @@ void main() {
String username, String username,
String password, String password,
) async { ) async {
final client = final client = ImapClient(
ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); defaultResponseTimeout: const Duration(seconds: 20),
);
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false); await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
await client.login(username, password); await client.login(username, password);
return client; return client;
@@ -73,7 +75,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());
@@ -6,15 +6,11 @@ import 'package:flutter_test/flutter_test.dart';
import '../../scripts/sync_reliability.dart' as reliability; import '../../scripts/sync_reliability.dart' as reliability;
void main() { void main() {
test( test('sync reliability script runner', timeout: Timeout.none, () async {
'sync reliability script runner', final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS'];
timeout: Timeout.none, final args = rawArgs == null || rawArgs.isEmpty
() async { ? const <String>[]
final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS']; : const LineSplitter().convert(rawArgs);
final args = rawArgs == null || rawArgs.isEmpty await reliability.runSyncReliability(args);
? const <String>[] });
: const LineSplitter().convert(rawArgs);
await reliability.runSyncReliability(args);
},
);
} }
+28 -28
View File
@@ -20,8 +20,9 @@ Future<ImapClient> _imapConnectPlain(
String username, String username,
String password, String password,
) async { ) async {
final client = final client = ImapClient(
ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); defaultResponseTimeout: const Duration(seconds: 20),
);
await client.connectToServer( await client.connectToServer(
account.imapHost, account.imapHost,
account.imapPort, account.imapPort,
@@ -64,11 +65,7 @@ void main() {
secureStorage = MapSecureStorage(); secureStorage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, secureStorage); final accounts = AccountRepositoryImpl(db, secureStorage);
await accounts.addAccount(account, userPass); await accounts.addAccount(account, userPass);
repo = EmailRepositoryImpl( repo = EmailRepositoryImpl(db, accounts, imapConnect: _imapConnectPlain);
db,
accounts,
imapConnect: _imapConnectPlain,
);
final client = await _imapConnectPlain(account, userEmail, userPass); final client = await _imapConnectPlain(account, userEmail, userPass);
await client.selectMailboxByPath('INBOX'); await client.selectMailboxByPath('INBOX');
@@ -107,26 +104,27 @@ void main() {
}); });
test( test(
'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.into(db.emails).insert( await db.into(db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'test:999', id: 'test:999',
accountId: 'test', accountId: 'test',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 999, uid: 999,
subject: const Value('Ghost'), subject: const Value('Ghost'),
receivedAt: DateTime.now(), receivedAt: DateTime.now(),
), ),
); );
// 2. Verify reliability // 2. Verify reliability
final result = await repo.verifySyncReliability('test', 'INBOX'); final result = await repo.verifySyncReliability('test', 'INBOX');
expect(result.isHealthy, isFalse); expect(result.isHealthy, isFalse);
expect(result.missingOnServer, contains('test:999')); expect(result.missingOnServer, contains('test:999'));
expect(result.missingLocally, isEmpty); expect(result.missingLocally, isEmpty);
}); },
);
test('verifySyncReliability identifies flag mismatches', () async { test('verifySyncReliability identifies flag mismatches', () async {
// 1. Sync one email // 1. Sync one email
@@ -195,8 +193,10 @@ void main() {
await client.logout(); await client.logout();
// 2. Need to find the JMAP mailbox ID for INBOX // 2. Need to find the JMAP mailbox ID for INBOX
final mailboxRepo = final mailboxRepo = MailboxRepositoryImpl(
MailboxRepositoryImpl(db, AccountRepositoryImpl(db, secureStorage)); db,
AccountRepositoryImpl(db, secureStorage),
);
await mailboxRepo.syncMailboxes('test-jmap'); await mailboxRepo.syncMailboxes('test-jmap');
final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first; final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first;
final inbox = mailboxes.firstWhere((m) => m.role == 'inbox'); final inbox = mailboxes.firstWhere((m) => m.role == 'inbox');
+69 -59
View File
@@ -39,36 +39,38 @@ void main() {
}); });
test( test(
'returns JmapDiscovery with session URL when well-known/jmap returns 200', 'returns JmapDiscovery with session URL when well-known/jmap returns 200',
() async { () async {
final svc = _service({ final svc = _service({
'https://example.com/.well-known/jmap': http.Response('{}', 200), 'https://example.com/.well-known/jmap': http.Response('{}', 200),
}); });
final result = await svc.discover('user@example.com'); final result = await svc.discover('user@example.com');
expect(result, isA<JmapDiscovery>()); expect(result, isA<JmapDiscovery>());
expect( expect(
(result as JmapDiscovery).sessionUrl, (result as JmapDiscovery).sessionUrl,
'https://example.com/.well-known/jmap', 'https://example.com/.well-known/jmap',
); );
}); },
);
test( test(
'returns JmapDiscovery with redirect target when well-known/jmap returns 307', 'returns JmapDiscovery with redirect target when well-known/jmap returns 307',
() async { () async {
final svc = _service({ final svc = _service({
'https://example.com/.well-known/jmap': http.Response( 'https://example.com/.well-known/jmap': http.Response(
'', '',
307, 307,
headers: {'location': '/jmap/session'}, headers: {'location': '/jmap/session'},
), ),
}); });
final result = await svc.discover('user@example.com'); final result = await svc.discover('user@example.com');
expect(result, isA<JmapDiscovery>()); expect(result, isA<JmapDiscovery>());
expect( expect(
(result as JmapDiscovery).sessionUrl, (result as JmapDiscovery).sessionUrl,
'https://example.com/jmap/session', 'https://example.com/jmap/session',
); );
}); },
);
test('returns UnknownDiscovery when well-known/jmap returns 404', () async { test('returns UnknownDiscovery when well-known/jmap returns 404', () async {
final svc = _service({ final svc = _service({
@@ -80,8 +82,10 @@ void main() {
test('returns ImapSmtpDiscovery from primary autoconfig URL', () async { test('returns ImapSmtpDiscovery from primary autoconfig URL', () async {
final svc = _service({ final svc = _service({
'https://autoconfig.example.com/mail/config-v1.1.xml': 'https://autoconfig.example.com/mail/config-v1.1.xml': http.Response(
http.Response(_autoconfigXml, 200), _autoconfigXml,
200,
),
}); });
final result = await svc.discover('user@example.com'); final result = await svc.discover('user@example.com');
expect(result, isA<ImapSmtpDiscovery>()); expect(result, isA<ImapSmtpDiscovery>());
@@ -94,21 +98,25 @@ void main() {
expect(imap.smtpSsl, isFalse); expect(imap.smtpSsl, isFalse);
}); });
test('returns ImapSmtpDiscovery from fallback well-known autoconfig URL', test(
() async { 'returns ImapSmtpDiscovery from fallback well-known autoconfig URL',
final svc = _service({ () async {
'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml': final svc = _service({
http.Response(_autoconfigXml, 200), 'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml':
}); http.Response(_autoconfigXml, 200),
final result = await svc.discover('user@example.com'); });
expect(result, isA<ImapSmtpDiscovery>()); final result = await svc.discover('user@example.com');
}); expect(result, isA<ImapSmtpDiscovery>());
},
);
test('prefers JMAP over IMAP when both respond', () async { test('prefers JMAP over IMAP when both respond', () async {
final svc = _service({ final svc = _service({
'https://example.com/.well-known/jmap': http.Response('{}', 200), 'https://example.com/.well-known/jmap': http.Response('{}', 200),
'https://autoconfig.example.com/mail/config-v1.1.xml': 'https://autoconfig.example.com/mail/config-v1.1.xml': http.Response(
http.Response(_autoconfigXml, 200), _autoconfigXml,
200,
),
}); });
final result = await svc.discover('user@example.com'); final result = await svc.discover('user@example.com');
expect(result, isA<JmapDiscovery>()); expect(result, isA<JmapDiscovery>());
@@ -120,24 +128,26 @@ void main() {
expect(result, isA<UnknownDiscovery>()); expect(result, isA<UnknownDiscovery>());
}); });
test('returns ImapSmtpDiscovery from MX record when autoconfig not found', test(
() async { 'returns ImapSmtpDiscovery from MX record when autoconfig not found',
final svc = _service({ () async {
'https://dns.google/resolve?name=example.com&type=MX': http.Response( final svc = _service({
'{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}', 'https://dns.google/resolve?name=example.com&type=MX': http.Response(
200, '{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}',
), 200,
}); ),
final result = await svc.discover('user@example.com'); });
expect(result, isA<ImapSmtpDiscovery>()); final result = await svc.discover('user@example.com');
final imap = result as ImapSmtpDiscovery; expect(result, isA<ImapSmtpDiscovery>());
expect(imap.imapHost, 'mail.example.com'); final imap = result as ImapSmtpDiscovery;
expect(imap.imapPort, 993); expect(imap.imapHost, 'mail.example.com');
expect(imap.imapSsl, isTrue); expect(imap.imapPort, 993);
expect(imap.smtpHost, 'mail.example.com'); expect(imap.imapSsl, isTrue);
expect(imap.smtpPort, 465); expect(imap.smtpHost, 'mail.example.com');
expect(imap.smtpSsl, isTrue); expect(imap.smtpPort, 465);
}); expect(imap.smtpSsl, isTrue);
},
);
test('MX fallback picks lowest priority record', () async { test('MX fallback picks lowest priority record', () async {
final svc = _service({ final svc = _service({
+2 -8
View File
@@ -82,10 +82,7 @@ void main() {
test('getPassword throws StateError when no password stored', () async { test('getPassword throws StateError when no password stored', () async {
final repo = _makeRepo(); final repo = _makeRepo();
expect( expect(() => repo.getPassword('missing'), throwsA(isA<StateError>()));
() => repo.getPassword('missing'),
throwsA(isA<StateError>()),
);
}); });
test('removeAccount deletes account and password', () async { test('removeAccount deletes account and password', () async {
@@ -94,10 +91,7 @@ void main() {
await repo.removeAccount('acc-1'); await repo.removeAccount('acc-1');
expect(await repo.getAccount('acc-1'), isNull); expect(await repo.getAccount('acc-1'), isNull);
expect( expect(() => repo.getPassword('acc-1'), throwsA(isA<StateError>()));
() => repo.getPassword('acc-1'),
throwsA(isA<StateError>()),
);
}); });
test('addAccount is idempotent (upsert)', () async { test('addAccount is idempotent (upsert)', () async {
+4 -11
View File
@@ -35,10 +35,8 @@ ConnectionTestServiceImpl _makeService({
Exception? imapError, Exception? imapError,
}) { }) {
final mockHttp = MockClient( final mockHttp = MockClient(
(_) async => http.Response( (_) async =>
httpStatus == 200 ? _jmapSessionJson : '', http.Response(httpStatus == 200 ? _jmapSessionJson : '', httpStatus),
httpStatus,
),
); );
return ConnectionTestServiceImpl( return ConnectionTestServiceImpl(
mockHttp, mockHttp,
@@ -97,10 +95,7 @@ void main() {
imapConnect: (_, __, ___) async => throw Exception('auth failed'), imapConnect: (_, __, ___) async => throw Exception('auth failed'),
smtpConnect: (_, __, ___) async => FakeSmtpClient(), smtpConnect: (_, __, ___) async => FakeSmtpClient(),
); );
expect( expect(() => svc.testConnection(_imapAccount, 'pw'), throwsException);
() => svc.testConnection(_imapAccount, 'pw'),
throwsException,
);
}); });
test('reports SMTP failure after IMAP success', () async { test('reports SMTP failure after IMAP success', () async {
@@ -192,9 +187,7 @@ void main() {
final svc = _makeService(httpStatus: 500); final svc = _makeService(httpStatus: 500);
expect( expect(
() => svc.testConnection(_jmapAccount, 'pw'), () => svc.testConnection(_jmapAccount, 'pw'),
throwsA( throwsA(predicate((e) => e.toString().contains('Connection failed'))),
predicate((e) => e.toString().contains('Connection failed')),
),
); );
}); });
+60 -54
View File
@@ -8,19 +8,21 @@ void main() {
setUpAll(configureSqliteForTests); setUpAll(configureSqliteForTests);
group('DraftRepositoryImpl', () { group('DraftRepositoryImpl', () {
test('saveDraft creates a new row and returns it with a non-zero id', test(
() async { 'saveDraft creates a new row and returns it with a non-zero id',
final repo = DraftRepositoryImpl(openTestDatabase()); () async {
final draft = await repo.saveDraft( final repo = DraftRepositoryImpl(openTestDatabase());
toText: 'bob@example.com', final draft = await repo.saveDraft(
ccText: '', toText: 'bob@example.com',
subjectText: 'Hello', ccText: '',
bodyText: 'Hi', subjectText: 'Hello',
); bodyText: 'Hi',
expect(draft.id, isNonZero); );
expect(draft.toText, 'bob@example.com'); expect(draft.id, isNonZero);
expect(draft.subjectText, 'Hello'); expect(draft.toText, 'bob@example.com');
}); expect(draft.subjectText, 'Hello');
},
);
test('saveDraft with id updates existing row', () async { test('saveDraft with id updates existing row', () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase());
@@ -54,48 +56,52 @@ void main() {
expect(await repo.findDraft(), isNull); expect(await repo.findDraft(), isNull);
}); });
test('findDraft returns most recent draft for matching replyToEmailId', test(
() async { 'findDraft returns most recent draft for matching replyToEmailId',
final repo = DraftRepositoryImpl(openTestDatabase()); () async {
await repo.saveDraft( final repo = DraftRepositoryImpl(openTestDatabase());
replyToEmailId: 'email-1', await repo.saveDraft(
toText: 'a@example.com', replyToEmailId: 'email-1',
ccText: '', toText: 'a@example.com',
subjectText: 'Older', ccText: '',
bodyText: '', subjectText: 'Older',
); bodyText: '',
final newer = await repo.saveDraft( );
replyToEmailId: 'email-1', final newer = await repo.saveDraft(
toText: 'a@example.com', replyToEmailId: 'email-1',
ccText: '', toText: 'a@example.com',
subjectText: 'Newer', ccText: '',
bodyText: 'body', subjectText: 'Newer',
); bodyText: 'body',
final found = await repo.findDraft(replyToEmailId: 'email-1'); );
expect(found?.id, newer.id); final found = await repo.findDraft(replyToEmailId: 'email-1');
expect(found?.subjectText, 'Newer'); expect(found?.id, newer.id);
}); expect(found?.subjectText, 'Newer');
},
);
test('findDraft with null replyToEmailId finds new-message drafts', test(
() async { 'findDraft with null replyToEmailId finds new-message drafts',
final repo = DraftRepositoryImpl(openTestDatabase()); () async {
// This draft is a reply and should NOT be returned. final repo = DraftRepositoryImpl(openTestDatabase());
await repo.saveDraft( // This draft is a reply and should NOT be returned.
replyToEmailId: 'email-1', await repo.saveDraft(
toText: 'x@example.com', replyToEmailId: 'email-1',
ccText: '', toText: 'x@example.com',
subjectText: 'Reply draft', ccText: '',
bodyText: '', subjectText: 'Reply draft',
); bodyText: '',
final newMsg = await repo.saveDraft( );
toText: 'y@example.com', final newMsg = await repo.saveDraft(
ccText: '', toText: 'y@example.com',
subjectText: 'New draft', ccText: '',
bodyText: '', subjectText: 'New draft',
); bodyText: '',
final found = await repo.findDraft(); );
expect(found?.id, newMsg.id); final found = await repo.findDraft();
}); expect(found?.id, newMsg.id);
},
);
test('deleteDraft removes the row', () async { test('deleteDraft removes the row', () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase());
+10 -4
View File
@@ -229,10 +229,16 @@ void main() {
group('SyncEmailsResult', () { group('SyncEmailsResult', () {
test('operator + adds fields', () { test('operator + adds fields', () {
const r1 = const r1 = SyncEmailsResult(
SyncEmailsResult(fetched: 1, skipped: 2, bytesTransferred: 100); fetched: 1,
const r2 = skipped: 2,
SyncEmailsResult(fetched: 3, skipped: 4, bytesTransferred: 200); bytesTransferred: 100,
);
const r2 = SyncEmailsResult(
fetched: 3,
skipped: 4,
bytesTransferred: 200,
);
final r3 = r1 + r2; final r3 = r1 + r2;
expect(r3.fetched, 4); expect(r3.fetched, 4);
expect(r3.skipped, 6); expect(r3.skipped, 6);
@@ -26,11 +26,7 @@ void main() {
mockHttpClient = MockClient(); mockHttpClient = MockClient();
mockStorage = MockSecureStorage(); mockStorage = MockSecureStorage();
final accounts = AccountRepositoryImpl(db, mockStorage); final accounts = AccountRepositoryImpl(db, mockStorage);
repo = EmailRepositoryImpl( repo = EmailRepositoryImpl(db, accounts, httpClient: mockHttpClient);
db,
accounts,
httpClient: mockHttpClient,
);
}); });
tearDown(() async { tearDown(() async {
+283 -268
View File
@@ -161,11 +161,8 @@ Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) => 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, _makeRepos({http.Client? httpClient}) {
AccountRepositoryImpl accounts,
EmailRepositoryImpl emails,
}) _makeRepos({http.Client? httpClient}) {
final db = openTestDatabase(); final db = openTestDatabase();
final storage = MapSecureStorage(); final storage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, storage); final accounts = AccountRepositoryImpl(db, storage);
@@ -421,53 +418,57 @@ void main() {
// ── IMAP method tests ──────────────────────────────────────────────────── // ── IMAP method tests ────────────────────────────────────────────────────
test('setFlag seen=true enqueues flag_seen change and updates local DB', test(
() async { 'setFlag seen=true enqueues flag_seen change and updates local DB',
final r = _makeRepos(); () async {
await r.accounts.addAccount(_account, 'pw'); final r = _makeRepos();
await r.db.into(r.db.emails).insert( await r.accounts.addAccount(_account, 'pw');
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:5', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:5',
mailboxPath: 'INBOX', accountId: 'acc-1',
uid: 5, mailboxPath: 'INBOX',
receivedAt: DateTime(2024), uid: 5,
), receivedAt: DateTime(2024),
); ),
);
await r.emails.setFlag('acc-1:5', seen: true); await r.emails.setFlag('acc-1:5', seen: true);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(1)); expect(changes, hasLength(1));
expect(changes.first.changeType, 'flag_seen'); expect(changes.first.changeType, 'flag_seen');
expect(changes.first.payload, contains('"seen":true')); expect(changes.first.payload, contains('"seen":true'));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
expect(email!.isSeen, isTrue); expect(email!.isSeen, isTrue);
}); },
);
test('setFlag seen=false enqueues flag_seen change with seen=false', test(
() async { 'setFlag seen=false enqueues flag_seen change with seen=false',
final r = _makeRepos(); () async {
await r.accounts.addAccount(_account, 'pw'); final r = _makeRepos();
await r.db.into(r.db.emails).insert( await r.accounts.addAccount(_account, 'pw');
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:5', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:5',
mailboxPath: 'INBOX', accountId: 'acc-1',
uid: 5, mailboxPath: 'INBOX',
receivedAt: DateTime(2024), uid: 5,
isSeen: const Value(true), receivedAt: DateTime(2024),
), isSeen: const Value(true),
); ),
);
await r.emails.setFlag('acc-1:5', seen: false); await r.emails.setFlag('acc-1:5', seen: false);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.changeType, 'flag_seen'); expect(changes.first.changeType, 'flag_seen');
expect(changes.first.payload, contains('"seen":false')); expect(changes.first.payload, contains('"seen":false'));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
expect(email!.isSeen, isFalse); expect(email!.isSeen, isFalse);
}); },
);
test('setFlag flagged=true enqueues flag_flagged change', () async { test('setFlag flagged=true enqueues flag_flagged change', () async {
final r = _makeRepos(); final r = _makeRepos();
@@ -491,74 +492,78 @@ void main() {
}); });
test( test(
'setFlag flagged=false enqueues flag_flagged change with flagged=false', 'setFlag flagged=false enqueues flag_flagged change with flagged=false',
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 5, uid: 5,
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
isFlagged: const Value(true), isFlagged: const Value(true),
), ),
); );
await r.emails.setFlag('acc-1:5', flagged: false); await r.emails.setFlag('acc-1:5', flagged: false);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.changeType, 'flag_flagged'); expect(changes.first.changeType, 'flag_flagged');
expect(changes.first.payload, contains('"flagged":false')); expect(changes.first.payload, contains('"flagged":false'));
}); },
);
test( test(
'moveEmail enqueues move change and updates local mailboxPath (optimistic)', 'moveEmail enqueues move change and updates local mailboxPath (optimistic)',
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 5, uid: 5,
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
await r.emails.moveEmail('acc-1:5', 'Archive'); await r.emails.moveEmail('acc-1:5', 'Archive');
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.changeType, 'move'); expect(changes.first.changeType, 'move');
expect(changes.first.payload, contains('Archive')); expect(changes.first.payload, contains('Archive'));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
expect(email, isNotNull); expect(email, isNotNull);
expect(email!.mailboxPath, 'Archive'); expect(email!.mailboxPath, 'Archive');
}); },
);
test('deleteEmail enqueues delete change and removes email from local DB', test(
() async { 'deleteEmail enqueues delete change and removes email from local DB',
final r = _makeRepos(); () async {
await r.accounts.addAccount(_account, 'pw'); final r = _makeRepos();
await r.db.into(r.db.emails).insert( await r.accounts.addAccount(_account, 'pw');
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:5', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:5',
mailboxPath: 'INBOX', accountId: 'acc-1',
uid: 5, mailboxPath: 'INBOX',
receivedAt: DateTime(2024), uid: 5,
), receivedAt: DateTime(2024),
); ),
);
await r.emails.deleteEmail('acc-1:5'); await r.emails.deleteEmail('acc-1:5');
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.changeType, 'delete'); expect(changes.first.changeType, 'delete');
expect(await r.emails.getEmail('acc-1:5'), isNull); expect(await r.emails.getEmail('acc-1:5'), isNull);
}); },
);
}); });
group('IMAP flushPendingChanges', () { group('IMAP flushPendingChanges', () {
@@ -729,11 +734,11 @@ void main() {
'2': {'value': html, 'isTruncated': false}, '2': {'value': html, 'isTruncated': false},
}, },
'attachments': [], 'attachments': [],
} },
], ],
}, },
'0', '0',
] ],
], ],
}), }),
200, 200,
@@ -800,11 +805,11 @@ void main() {
'htmlBody': [], 'htmlBody': [],
'bodyValues': <String, dynamic>{}, 'bodyValues': <String, dynamic>{},
'attachments': [], 'attachments': [],
} },
], ],
}, },
'0', '0',
] ],
], ],
}), }),
200, 200,
@@ -975,10 +980,12 @@ void main() {
final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first; final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first;
expect(emails, hasLength(4)); expect(emails, hasLength(4));
expect( expect(emails.map((e) => e.subject).toSet(), {
emails.map((e) => e.subject).toSet(), 'Page1-A',
{'Page1-A', 'Page1-B', 'Page2-A', 'Page2-B'}, 'Page1-B',
); 'Page2-A',
'Page2-B',
});
final states = await r.db.select(r.db.syncStates).get(); final states = await r.db.select(r.db.syncStates).get();
expect(states.first.state, 'est1'); expect(states.first.state, 'est1');
@@ -1002,21 +1009,23 @@ void main() {
); );
} }
test('setFlag seen enqueues flag_seen change and updates local DB', test(
() async { 'setFlag seen enqueues flag_seen change and updates local DB',
final r = _makeRepos(); () async {
await seedJmapEmail(r.db, r.accounts); final r = _makeRepos();
await seedJmapEmail(r.db, r.accounts);
await r.emails.setFlag('jmap-1:e1', seen: true); await r.emails.setFlag('jmap-1:e1', seen: true);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(1)); expect(changes, hasLength(1));
expect(changes.first.changeType, 'flag_seen'); expect(changes.first.changeType, 'flag_seen');
expect(changes.first.payload, contains('true')); expect(changes.first.payload, contains('true'));
final email = await r.emails.getEmail('jmap-1:e1'); final email = await r.emails.getEmail('jmap-1:e1');
expect(email?.isSeen, isTrue); expect(email?.isSeen, isTrue);
}); },
);
test('setFlag flagged enqueues flag_flagged change', () async { test('setFlag flagged enqueues flag_flagged change', () async {
final r = _makeRepos(); final r = _makeRepos();
@@ -1028,33 +1037,37 @@ void main() {
expect(changes.first.changeType, 'flag_flagged'); expect(changes.first.changeType, 'flag_flagged');
}); });
test('moveEmail enqueues move change and updates local mailbox path', test(
() async { 'moveEmail enqueues move change and updates local mailbox path',
final r = _makeRepos(); () async {
await seedJmapEmail(r.db, r.accounts); final r = _makeRepos();
await seedJmapEmail(r.db, r.accounts);
await r.emails.moveEmail('jmap-1:e1', 'mbx2'); await r.emails.moveEmail('jmap-1:e1', 'mbx2');
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.changeType, 'move'); expect(changes.first.changeType, 'move');
expect(changes.first.payload, contains('mbx2')); expect(changes.first.payload, contains('mbx2'));
final email = await r.emails.getEmail('jmap-1:e1'); final email = await r.emails.getEmail('jmap-1:e1');
expect(email, isNotNull); expect(email, isNotNull);
expect(email?.mailboxPath, 'mbx2'); expect(email?.mailboxPath, 'mbx2');
}); },
);
test('deleteEmail enqueues delete change and removes email from local DB', test(
() async { 'deleteEmail enqueues delete change and removes email from local DB',
final r = _makeRepos(); () async {
await seedJmapEmail(r.db, r.accounts); final r = _makeRepos();
await seedJmapEmail(r.db, r.accounts);
await r.emails.deleteEmail('jmap-1:e1'); await r.emails.deleteEmail('jmap-1:e1');
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.changeType, 'delete'); expect(changes.first.changeType, 'delete');
expect(await r.emails.getEmail('jmap-1:e1'), isNull); expect(await r.emails.getEmail('jmap-1:e1'), isNull);
}); },
);
}); });
group('JMAP flushPendingChanges', () { group('JMAP flushPendingChanges', () {
@@ -1246,130 +1259,134 @@ void main() {
expect(states.first.state, 'est2'); expect(states.first.state, 'est2');
}); });
test('stateMismatch clears sync state and marks change as failed', test(
() async { 'stateMismatch clears sync state and marks change as failed',
final client = MockClient((req) async { () async {
if (req.url.path.contains('well-known')) { final client = 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': {'acct1': {}}, 'apiUrl': 'https://jmap.example.com/api/',
'primaryAccounts': { 'accounts': {'acct1': {}},
'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',
'state': 'sess1',
}),
200,
);
}
// Server responds with stateMismatch error inside Email/set
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Email/set',
{'accountId': 'acct1', 'type': 'stateMismatch'},
'0',
],
],
}),
200,
);
});
final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Email',
state: 'est1',
syncedAt: DateTime.now(),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Email',
resourceId: 'jmap-1:e1',
changeType: 'flag_seen',
payload: '{"seen":true}',
createdAt: DateTime.now(),
),
);
await r.emails.flushPendingChanges('jmap-1', 'pw');
// Sync state should be cleared so next cycle does a full re-sync
expect(await r.db.select(r.db.syncStates).get(), isEmpty);
// Change should still be present but with attempt count bumped
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.attempts, 1);
});
test('discards change immediately on notUpdated (permanent error)',
() async {
final client = MockClient((req) async {
if (req.url.path.contains('well-known')) {
return http.Response(
jsonEncode({
'apiUrl': 'https://jmap.example.com/api/',
'accounts': {'acct1': {}},
'primaryAccounts': {
'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1',
},
'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
// Server responds with notUpdated — permanent per-item error
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Email/set',
{
'accountId': 'acct1',
'notUpdated': {
'e1': {'type': 'notFound'},
},
}, },
'0', 'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
// Server responds with stateMismatch error inside Email/set
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Email/set',
{'accountId': 'acct1', 'type': 'stateMismatch'},
'0',
],
], ],
], }),
}), 200,
200,
);
});
final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Email',
resourceId: 'jmap-1:e1',
changeType: 'flag_seen',
payload: '{"seen":true}',
createdAt: DateTime.now(),
),
); );
});
await r.emails.flushPendingChanges('jmap-1', 'pw'); final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Email',
state: 'est1',
syncedAt: DateTime.now(),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Email',
resourceId: 'jmap-1:e1',
changeType: 'flag_seen',
payload: '{"seen":true}',
createdAt: DateTime.now(),
),
);
// Permanent error — change is immediately evicted await r.emails.flushPendingChanges('jmap-1', 'pw');
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); // Sync state should be cleared so next cycle does a full re-sync
expect(await r.db.select(r.db.syncStates).get(), isEmpty);
// Change should still be present but with attempt count bumped
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes.first.attempts, 1);
},
);
test(
'discards change immediately on notUpdated (permanent error)',
() async {
final client = MockClient((req) async {
if (req.url.path.contains('well-known')) {
return http.Response(
jsonEncode({
'apiUrl': 'https://jmap.example.com/api/',
'accounts': {'acct1': {}},
'primaryAccounts': {
'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1',
},
'capabilities': {},
'username': 'alice@example.com',
'state': 'sess1',
}),
200,
);
}
// Server responds with notUpdated — permanent per-item error
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Email/set',
{
'accountId': 'acct1',
'notUpdated': {
'e1': {'type': 'notFound'},
},
},
'0',
],
],
}),
200,
);
});
final r = _makeRepos(httpClient: client);
await r.accounts.addAccount(_jmapAccount, 'pw');
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'jmap-1',
resourceType: 'Email',
resourceId: 'jmap-1:e1',
changeType: 'flag_seen',
payload: '{"seen":true}',
createdAt: DateTime.now(),
),
);
await r.emails.flushPendingChanges('jmap-1', 'pw');
// Permanent error — change is immediately evicted
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
},
);
test('evicts change after max attempts (5)', () async { test('evicts change after max attempts (5)', () async {
final r = _makeRepos(httpClient: mockFlush(500)); final r = _makeRepos(httpClient: mockFlush(500));
@@ -1459,9 +1476,7 @@ void main() {
apiResponses: [ apiResponses: [
_emailGetResponse( _emailGetResponse(
state: 'est1', state: 'est1',
list: [ list: [_jmapEmail(id: 'e1', mailboxId: 'mbx1')],
_jmapEmail(id: 'e1', mailboxId: 'mbx1'),
],
), ),
], ],
), ),
+6 -15
View File
@@ -43,13 +43,7 @@ http.Client _sessionClient({
); );
} }
return http.Response( return http.Response(
jsonEncode( jsonEncode(apiBody ?? {'sessionState': 'st1', 'methodResponses': []}),
apiBody ??
{
'sessionState': 'st1',
'methodResponses': [],
},
),
apiStatus, apiStatus,
); );
}); });
@@ -137,10 +131,7 @@ void main() {
}); });
group('JmapClient.call', () { group('JmapClient.call', () {
Future<JmapClient> connected({ Future<JmapClient> connected({int apiStatus = 200, dynamic apiBody}) =>
int apiStatus = 200,
dynamic apiBody,
}) =>
JmapClient.connect( JmapClient.connect(
httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody), httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody),
jmapUrl: Uri.parse(_sessionUrl), jmapUrl: Uri.parse(_sessionUrl),
@@ -154,7 +145,7 @@ void main() {
'Mailbox/get', 'Mailbox/get',
<String, dynamic>{'state': 'st2', 'list': []}, <String, dynamic>{'state': 'st2', 'list': []},
'0', '0',
] ],
]; ];
final client = await connected( final client = await connected(
apiBody: {'sessionState': 'st1', 'methodResponses': responses}, apiBody: {'sessionState': 'st1', 'methodResponses': responses},
@@ -164,7 +155,7 @@ void main() {
'Mailbox/get', 'Mailbox/get',
{'accountId': _accountId, 'ids': null}, {'accountId': _accountId, 'ids': null},
'0', '0',
] ],
]); ]);
expect(result, hasLength(1)); expect(result, hasLength(1));
expect((result[0] as List<dynamic>)[0], 'Mailbox/get'); expect((result[0] as List<dynamic>)[0], 'Mailbox/get');
@@ -178,7 +169,7 @@ void main() {
'Mailbox/get', 'Mailbox/get',
{'accountId': _accountId}, {'accountId': _accountId},
'0', '0',
] ],
]), ]),
throwsA(isA<JmapException>()), throwsA(isA<JmapException>()),
); );
@@ -194,7 +185,7 @@ void main() {
'Mailbox/get', 'Mailbox/get',
{'accountId': _accountId}, {'accountId': _accountId},
'0', '0',
] ],
]), ]),
throwsA(isA<JmapException>()), throwsA(isA<JmapException>()),
); );
+1 -4
View File
@@ -130,10 +130,7 @@ void main() {
}); });
test('copyWith works', () { test('copyWith works', () {
final updated = mailbox.copyWith( final updated = mailbox.copyWith(unreadCount: 5, role: 'inbox');
unreadCount: 5,
role: 'inbox',
);
expect(updated.unreadCount, 5); expect(updated.unreadCount, 5);
expect(updated.role, 'inbox'); expect(updated.role, 'inbox');
expect(updated.id, mailbox.id); expect(updated.id, mailbox.id);
+64 -59
View File
@@ -155,47 +155,50 @@ void main() {
} }
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first; final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect( expect(mailboxes.map((m) => m.path).toList(), [
mailboxes.map((m) => m.path).toList(), 'Drafts',
['Drafts', 'INBOX', 'Sent'], 'INBOX',
); 'Sent',
]);
}); });
test('observeMailboxes only returns mailboxes for the given account', test(
() async { 'observeMailboxes only returns mailboxes for the given account',
final r = _makeRepos(); () async {
await r.accounts.addAccount(_account, 'pw'); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
const other = Account( const other = Account(
id: 'acc-2', id: 'acc-2',
displayName: 'Bob', displayName: 'Bob',
email: 'bob@example.com', email: 'bob@example.com',
imapHost: 'imap.example.com', imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com', smtpHost: 'smtp.example.com',
); );
await r.accounts.addAccount(other, 'pw2'); await r.accounts.addAccount(other, 'pw2');
await r.db.into(r.db.mailboxes).insert( await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:INBOX', id: 'acc-1:INBOX',
accountId: 'acc-1', accountId: 'acc-1',
path: 'INBOX', path: 'INBOX',
name: 'Inbox', name: 'Inbox',
), ),
); );
await r.db.into(r.db.mailboxes).insert( await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-2:INBOX', id: 'acc-2:INBOX',
accountId: 'acc-2', accountId: 'acc-2',
path: 'INBOX', path: 'INBOX',
name: 'Inbox', name: 'Inbox',
), ),
); );
final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first; final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first;
expect(mailboxes, hasLength(1)); expect(mailboxes, hasLength(1));
expect(mailboxes.first.id, 'acc-1:INBOX'); expect(mailboxes.first.id, 'acc-1:INBOX');
}); },
);
test('observeMailboxes maps unread/total counts', () async { test('observeMailboxes maps unread/total counts', () async {
final r = _makeRepos(); final r = _makeRepos();
@@ -377,30 +380,32 @@ void main() {
); );
}); });
test('syncMailboxes throws JmapException on API error response', test(
() async { 'syncMailboxes throws JmapException on API error response',
final r = _makeRepos( () async {
httpClient: _mockJmap( final r = _makeRepos(
apiResponses: [ httpClient: _mockJmap(
{ apiResponses: [
'sessionState': 'sess1', {
'methodResponses': [ 'sessionState': 'sess1',
[ 'methodResponses': [
'error', [
<String, dynamic>{'type': 'serverFail'}, 'error',
'0', <String, dynamic>{'type': 'serverFail'},
'0',
],
], ],
], },
}, ],
], ),
), );
); await r.accounts.addAccount(_jmapAccount, 'pw');
await r.accounts.addAccount(_jmapAccount, 'pw'); await expectLater(
await expectLater( r.mailboxes.syncMailboxes('jmap-1'),
r.mailboxes.syncMailboxes('jmap-1'), throwsA(isA<JmapException>()),
throwsA(isA<JmapException>()), );
); },
}); );
}); });
test('findMailboxByRole returns null when no matching mailbox', () async { test('findMailboxByRole returns null when no matching mailbox', () async {
+3 -2
View File
@@ -75,8 +75,9 @@ void main() {
finishedAt: end, finishedAt: end,
); );
final rows = await (db.select(db.syncLogs) final rows = await (db.select(
..where((r) => r.result.equals('error'))) db.syncLogs,
)..where((r) => r.result.equals('error')))
.get(); .get();
expect(rows, hasLength(1)); expect(rows, hasLength(1));
expect(rows.first.result, 'error'); expect(rows.first.result, 'error');
+40 -39
View File
@@ -205,50 +205,51 @@ void main() {
); );
}); });
test('Undo deletion for IMAP enqueues reverse move if cancel fails', test(
() async { 'Undo deletion for IMAP enqueues reverse move if cancel fails',
const emailId = 'acc1:101'; () async {
final original = await repo.getEmail(emailId); const emailId = 'acc1:101';
final original = await repo.getEmail(emailId);
// 1. Delete // 1. Delete
final destPath = await repo.deleteEmail(emailId); final destPath = await repo.deleteEmail(emailId);
expect(destPath, 'Trash'); expect(destPath, 'Trash');
// 2. Mark the pending change as "attempted" so it cannot be cancelled // 2. Mark the pending change as "attempted" so it cannot be cancelled
await (db.update(db.pendingChanges) await (db.update(db.pendingChanges)
..where((t) => t.resourceId.equals(emailId))) ..where((t) => t.resourceId.equals(emailId)))
.write( .write(const PendingChangesCompanion(attempts: Value(1)));
const PendingChangesCompanion(attempts: Value(1)),
);
// 3. Undo // 3. Undo
final action = UndoAction( final action = UndoAction(
id: 'undo3', id: 'undo3',
accountId: 'acc1', accountId: 'acc1',
type: UndoType.delete, type: UndoType.delete,
emailIds: [emailId], emailIds: [emailId],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
destinationMailboxPath: destPath, destinationMailboxPath: destPath,
originalEmails: [original!], originalEmails: [original!],
); );
container.read(undoServiceProvider.notifier).pushAction(action); container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 4. Verify local state // 4. Verify local state
final restored = await (db.select(db.emails) final restored = await (db.select(db.emails)
..where((t) => t.id.equals(emailId)) ..where((t) => t.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)
final changes = await db.select(db.pendingChanges).get(); final changes = await db.select(db.pendingChanges).get();
final reverseMove = final reverseMove = changes.firstWhere(
changes.firstWhere((c) => c.changeType == 'move' && c.attempts == 0); (c) => c.changeType == 'move' && c.attempts == 0,
final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>; );
expect(payload['mailboxPath'], 'Trash'); final payload = jsonDecode(reverseMove.payload) as Map<String, dynamic>;
expect(payload['dest'], 'INBOX'); expect(payload['mailboxPath'], 'Trash');
}); expect(payload['dest'], 'INBOX');
},
);
test('Undo snooze clears snooze metadata and moves back', () async { test('Undo snooze clears snooze metadata and moves back', () async {
const emailId = 'acc1:101'; const emailId = 'acc1:101';
+18 -12
View File
@@ -22,8 +22,9 @@ void main() {
when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {}); when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {});
when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {}); when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {});
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) when(
.thenAnswer((_) async => []); mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
container = ProviderContainer( container = ProviderContainer(
overrides: [ overrides: [
@@ -84,8 +85,9 @@ void main() {
); );
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when(mockEmailRepo.cancelPendingChange(any, any)) when(
.thenAnswer((_) async => false); mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
@@ -118,8 +120,9 @@ void main() {
); );
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when(mockEmailRepo.cancelPendingChange(any, any)) when(
.thenAnswer((_) async => false); mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
@@ -142,10 +145,12 @@ void main() {
); );
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when(mockEmailRepo.cancelPendingChange('e1', 'delete')) when(
.thenAnswer((_) async => false); mockEmailRepo.cancelPendingChange('e1', 'delete'),
when(mockEmailRepo.cancelPendingChange('e1', 'move')) ).thenAnswer((_) async => false);
.thenAnswer((_) async => true); when(
mockEmailRepo.cancelPendingChange('e1', 'move'),
).thenAnswer((_) async => true);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
@@ -180,8 +185,9 @@ void main() {
originalEmails: [email], originalEmails: [email],
); );
when(mockEmailRepo.cancelPendingChange(any, any)) when(
.thenAnswer((_) async => false); mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false);
when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {}); when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {});
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
+26 -20
View File
@@ -5,8 +5,9 @@ import 'helpers.dart';
void main() { void main() {
group('AccountListScreen', () { group('AccountListScreen', () {
testWidgets('shows "No accounts yet." when repository is empty', testWidgets('shows "No accounts yet." when repository is empty', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()), buildApp(initialLocation: '/accounts', overrides: baseOverrides()),
); );
@@ -16,8 +17,9 @@ void main() {
expect(find.text('Add account'), findsOneWidget); expect(find.text('Add account'), findsOneWidget);
}); });
testWidgets('shows account tile when repository has an account', testWidgets('shows account tile when repository has an account', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts', initialLocation: '/accounts',
@@ -44,8 +46,9 @@ void main() {
expect(find.textContaining('IMAP'), findsOneWidget); expect(find.textContaining('IMAP'), findsOneWidget);
}); });
testWidgets('shows check icon after successful connection test', testWidgets('shows check icon after successful connection test', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts', initialLocation: '/accounts',
@@ -87,21 +90,23 @@ void main() {
}); });
testWidgets( testWidgets(
'"Add account" button in empty state navigates to add-account screen', '"Add account" button in empty state navigates to add-account screen',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()), buildApp(initialLocation: '/accounts', overrides: baseOverrides()),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Add account')); await tester.tap(find.text('Add account'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Email address'), findsOneWidget); expect(find.text('Email address'), findsOneWidget);
}); },
);
testWidgets('tapping an account tile navigates to its mailboxes', testWidgets('tapping an account tile navigates to its mailboxes', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts', initialLocation: '/accounts',
@@ -131,8 +136,9 @@ void main() {
expect(find.text('Add account'), findsOneWidget); expect(find.text('Add account'), findsOneWidget);
}); });
testWidgets('AppBar does not overflow at minimum supported window size', testWidgets('AppBar does not overflow at minimum supported window size', (
(tester) async { tester,
) async {
tester.view.physicalSize = const Size(400, 300); tester.view.physicalSize = const Size(400, 300);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
+66 -65
View File
@@ -7,13 +7,11 @@ import 'helpers.dart';
void main() { void main() {
group('AddAccountScreen', () { group('AddAccountScreen', () {
testWidgets('step 1: shows email field and Continue button', testWidgets('step 1: shows email field and Continue button', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
initialLocation: '/accounts/add',
overrides: baseOverrides(),
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -24,10 +22,7 @@ void main() {
testWidgets('step 1: empty submit shows validation error', (tester) async { testWidgets('step 1: empty submit shows validation error', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
initialLocation: '/accounts/add',
overrides: baseOverrides(),
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -39,10 +34,7 @@ void main() {
testWidgets('step 1: invalid email shows validation error', (tester) async { testWidgets('step 1: invalid email shows validation error', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()),
initialLocation: '/accounts/add',
overrides: baseOverrides(),
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -73,14 +65,16 @@ void main() {
expect(find.text('IMAP / SMTP'), findsOneWidget); expect(find.text('IMAP / SMTP'), findsOneWidget);
}); });
testWidgets('JMAP discovery navigates directly to JMAP form', testWidgets('JMAP discovery navigates directly to JMAP form', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/add', initialLocation: '/accounts/add',
overrides: baseOverrides( overrides: baseOverrides(
discovery: discovery: JmapDiscovery(
JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'), sessionUrl: 'https://mail.example.com/jmap',
),
), ),
), ),
); );
@@ -97,8 +91,9 @@ void main() {
expect(find.text('https://mail.example.com/jmap'), findsOneWidget); expect(find.text('https://mail.example.com/jmap'), findsOneWidget);
}); });
testWidgets('IMAP discovery navigates directly to IMAP form', testWidgets('IMAP discovery navigates directly to IMAP form', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/add', initialLocation: '/accounts/add',
@@ -150,8 +145,9 @@ void main() {
expect(find.text('JMAP API URL'), findsOneWidget); expect(find.text('JMAP API URL'), findsOneWidget);
}); });
testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form', testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/add', initialLocation: '/accounts/add',
@@ -174,14 +170,16 @@ void main() {
expect(find.text('SMTP'), findsOneWidget); expect(find.text('SMTP'), findsOneWidget);
}); });
testWidgets('successful JMAP save pops back to accounts list', testWidgets('successful JMAP save pops back to accounts list', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/add', initialLocation: '/accounts/add',
overrides: baseOverrides( overrides: baseOverrides(
discovery: discovery: JmapDiscovery(
JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'), sessionUrl: 'https://mail.example.com/jmap',
),
), ),
), ),
); );
@@ -213,8 +211,9 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/add', initialLocation: '/accounts/add',
overrides: baseOverrides( overrides: baseOverrides(
discovery: discovery: JmapDiscovery(
JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'), sessionUrl: 'https://mail.example.com/jmap',
),
connectionError: Exception('auth failed'), connectionError: Exception('auth failed'),
), ),
), ),
@@ -242,8 +241,9 @@ void main() {
expect(find.textContaining('Connection failed'), findsOneWidget); expect(find.textContaining('Connection failed'), findsOneWidget);
}); });
testWidgets('successful IMAP save pops back to accounts list', testWidgets('successful IMAP save pops back to accounts list', (
(tester) async { tester,
) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
@@ -288,45 +288,46 @@ void main() {
}); });
testWidgets( testWidgets(
'IMAP form hides SSL toggle for non-localhost, shows for localhost', 'IMAP form hides SSL toggle for non-localhost, shows for localhost',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/add', initialLocation: '/accounts/add',
overrides: baseOverrides(discovery: UnknownDiscovery()), overrides: baseOverrides(discovery: UnknownDiscovery()),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText( await tester.enterText(
find.byKey(const Key('emailField')), find.byKey(const Key('emailField')),
'user@example.com', 'user@example.com',
); );
await tester.tap(find.text('Continue')); await tester.tap(find.text('Continue'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('IMAP / SMTP')); await tester.tap(find.text('IMAP / SMTP'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('IMAP'), findsOneWidget); expect(find.text('IMAP'), findsOneWidget);
// No SSL toggles shown when hosts are empty (non-localhost). // No SSL toggles shown when hosts are empty (non-localhost).
expect(find.byType(SwitchListTile), findsNothing); expect(find.byType(SwitchListTile), findsNothing);
// Entering localhost as IMAP host reveals the IMAP SSL toggle. // Entering localhost as IMAP host reveals the IMAP SSL toggle.
await tester.enterText( await tester.enterText(
find.widgetWithText(TextFormField, 'Host').first, find.widgetWithText(TextFormField, 'Host').first,
'localhost', 'localhost',
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(SwitchListTile), findsOneWidget); expect(find.byType(SwitchListTile), findsOneWidget);
// Entering localhost as SMTP host reveals both SSL toggles. // Entering localhost as SMTP host reveals both SSL toggles.
await tester.enterText( await tester.enterText(
find.widgetWithText(TextFormField, 'Host').last, find.widgetWithText(TextFormField, 'Host').last,
'localhost', 'localhost',
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(SwitchListTile), findsNWidgets(2)); expect(find.byType(SwitchListTile), findsNWidgets(2));
}); },
);
}); });
} }
+40 -29
View File
@@ -16,10 +16,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/compose', initialLocation: '/compose',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
], ],
@@ -33,8 +35,9 @@ void main() {
expect(find.text('Body'), findsOneWidget); expect(find.text('Body'), findsOneWidget);
}); });
testWidgets('prefills To and Subject when provided as constructor params', testWidgets('prefills To and Subject when provided as constructor params', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
_buildDirect( _buildDirect(
screen: const ComposeScreen( screen: const ComposeScreen(
@@ -42,10 +45,12 @@ void main() {
prefillSubject: 'Re: Hello', prefillSubject: 'Re: Hello',
), ),
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
], ],
@@ -60,16 +65,19 @@ void main() {
expect(find.widgetWithText(TextFormField, 'Re: Hello'), findsOneWidget); expect(find.widgetWithText(TextFormField, 'Re: Hello'), findsOneWidget);
}); });
testWidgets('shows static From field when one account is loaded', testWidgets('shows static From field when one account is loaded', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/compose', initialLocation: '/compose',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
], ],
@@ -80,8 +88,9 @@ void main() {
expect(find.text('Alice <alice@example.com>'), findsOneWidget); expect(find.text('Alice <alice@example.com>'), findsOneWidget);
}); });
testWidgets('shows From dropdown when multiple accounts are loaded', testWidgets('shows From dropdown when multiple accounts are loaded', (
(tester) async { tester,
) async {
const second = Account( const second = Account(
id: 'acc-2', id: 'acc-2',
displayName: 'Bob', displayName: 'Bob',
@@ -96,8 +105,9 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount, second]), FakeAccountRepository([kTestAccount, second]),
), ),
mailboxRepositoryProvider mailboxRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeMailboxRepository()), FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
], ],
@@ -108,8 +118,9 @@ void main() {
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget); expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
}); });
testWidgets('restores saved draft when no prefill is provided', testWidgets('restores saved draft when no prefill is provided', (
(tester) async { tester,
) async {
final fakeDrafts = FakeDraftRepository(); final fakeDrafts = FakeDraftRepository();
await fakeDrafts.saveDraft( await fakeDrafts.saveDraft(
toText: 'carol@example.com', toText: 'carol@example.com',
@@ -121,10 +132,12 @@ void main() {
_buildDirect( _buildDirect(
screen: const ComposeScreen(), screen: const ComposeScreen(),
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(fakeDrafts), draftRepositoryProvider.overrideWithValue(fakeDrafts),
], ],
@@ -152,9 +165,7 @@ Widget _buildDirect({
}) { }) {
final router = GoRouter( final router = GoRouter(
initialLocation: '/', initialLocation: '/',
routes: [ routes: [GoRoute(path: '/', builder: (ctx, state) => screen)],
GoRoute(path: '/', builder: (ctx, state) => screen),
],
); );
return ProviderScope( return ProviderScope(
overrides: overrides, overrides: overrides,
+3 -2
View File
@@ -23,8 +23,9 @@ class MockUrlLauncher extends Mock
} }
void main() { void main() {
testWidgets('CrashScreen shows error details and has a report button', testWidgets('CrashScreen shows error details and has a report button', (
(tester) async { tester,
) async {
tester.view.physicalSize = const Size(800, 1200); tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize()); addTearDown(() => tester.view.resetPhysicalSize());
+6 -4
View File
@@ -5,8 +5,9 @@ import 'helpers.dart';
void main() { void main() {
group('EditAccountScreen', () { group('EditAccountScreen', () {
testWidgets('shows account email and type label after loading', testWidgets('shows account email and type label after loading', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/edit', initialLocation: '/accounts/acc-1/edit',
@@ -65,8 +66,9 @@ void main() {
expect(find.text('No accounts yet.'), findsNothing); expect(find.text('No accounts yet.'), findsNothing);
}); });
testWidgets('saving with new password runs connection test', testWidgets('saving with new password runs connection test', (
(tester) async { tester,
) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
+32 -20
View File
@@ -17,10 +17,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(neverRepo), emailRepositoryProvider.overrideWithValue(neverRepo),
], ],
), ),
@@ -42,10 +44,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body), FakeEmailRepository(emailDetail: email, emailBody: body),
), ),
@@ -61,16 +65,21 @@ void main() {
testWidgets('shows from-address in header', (tester) async { testWidgets('shows from-address in header', (tester) async {
final email = testEmail(); final email = testEmail();
const body = const body = EmailBody(
EmailBody(emailId: 'acc-1:42', textBody: 'Hi', attachments: []); emailId: 'acc-1:42',
textBody: 'Hi',
attachments: [],
);
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body), FakeEmailRepository(emailDetail: email, emailBody: body),
), ),
@@ -82,8 +91,9 @@ void main() {
expect(find.textContaining('bob@example.com'), findsOneWidget); expect(find.textContaining('bob@example.com'), findsOneWidget);
}); });
testWidgets('shows attachment section when email has attachments', testWidgets('shows attachment section when email has attachments', (
(tester) async { tester,
) async {
final email = testEmail(hasAttachment: true); final email = testEmail(hasAttachment: true);
const body = EmailBody( const body = EmailBody(
emailId: 'acc-1:42', emailId: 'acc-1:42',
@@ -100,10 +110,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body), FakeEmailRepository(emailDetail: email, emailBody: body),
), ),
+133 -88
View File
@@ -15,10 +15,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -34,12 +36,15 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
emailRepositoryProvider FakeMailboxRepository(),
.overrideWithValue(FakeEmailRepository(emails: [email])), ),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
], ],
), ),
); );
@@ -55,12 +60,15 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
emailRepositoryProvider FakeMailboxRepository(),
.overrideWithValue(FakeEmailRepository(emails: [email])), ),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
], ],
), ),
); );
@@ -74,10 +82,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -91,16 +101,19 @@ void main() {
expect(find.text('Search…'), findsOneWidget); expect(find.text('Search…'), findsOneWidget);
}); });
testWidgets('submitting a search query shows "No results" when empty', testWidgets('submitting a search query shows "No results" when empty', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -117,17 +130,20 @@ void main() {
expect(find.text('No results'), findsOneWidget); expect(find.text('No results'), findsOneWidget);
}); });
testWidgets('submitting a search query shows matching emails', testWidgets('submitting a search query shows matching emails', (
(tester) async { tester,
) async {
final email = testEmail(subject: 'Found it'); final email = testEmail(subject: 'Found it');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]), FakeEmailRepository(searchResults: [email]),
), ),
@@ -151,10 +167,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -167,16 +185,19 @@ void main() {
// No assertion needed — we just verify the tap doesn't throw. // No assertion needed — we just verify the tap doesn't throw.
}); });
testWidgets('tapping edit button navigates to compose screen', testWidgets('tapping edit button navigates to compose screen', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -194,10 +215,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -209,19 +232,23 @@ void main() {
expect(find.text('INBOX'), findsOneWidget); expect(find.text('INBOX'), findsOneWidget);
}); });
testWidgets('long-press enters selection mode with selection bar', testWidgets('long-press enters selection mode with selection bar', (
(tester) async { tester,
) async {
final email = testEmail(subject: 'Select me'); final email = testEmail(subject: 'Select me');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
emailRepositoryProvider FakeMailboxRepository(),
.overrideWithValue(FakeEmailRepository(emails: [email])), ),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
], ],
), ),
); );
@@ -235,19 +262,23 @@ void main() {
expect(find.byIcon(Icons.close), findsOneWidget); expect(find.byIcon(Icons.close), findsOneWidget);
}); });
testWidgets('selection bar close button exits selection mode', testWidgets('selection bar close button exits selection mode', (
(tester) async { tester,
) async {
final email = testEmail(subject: 'Select me'); final email = testEmail(subject: 'Select me');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
emailRepositoryProvider FakeMailboxRepository(),
.overrideWithValue(FakeEmailRepository(emails: [email])), ),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
], ],
), ),
); );
@@ -263,17 +294,20 @@ void main() {
expect(find.byType(BottomAppBar), findsNothing); expect(find.byType(BottomAppBar), findsNothing);
}); });
testWidgets('tapping clear icon in search bar clears results', testWidgets('tapping clear icon in search bar clears results', (
(tester) async { tester,
) async {
final email = testEmail(subject: 'Found it'); final email = testEmail(subject: 'Found it');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [], searchResults: [email]), FakeEmailRepository(emails: [], searchResults: [email]),
), ),
@@ -303,12 +337,15 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
emailRepositoryProvider FakeMailboxRepository(),
.overrideWithValue(FakeEmailRepository(emails: [email])), ),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
], ],
), ),
); );
@@ -325,23 +362,28 @@ void main() {
expect(find.text('INBOX'), findsOneWidget); expect(find.text('INBOX'), findsOneWidget);
}); });
testWidgets('tapping a search result navigates to email detail', testWidgets('tapping a search result navigates to email detail', (
(tester) async { tester,
) async {
final email = testEmail(subject: 'Result email'); final email = testEmail(subject: 'Result email');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
searchResults: [email], searchResults: [email],
emailDetail: email, emailDetail: email,
emailBody: emailBody: const EmailBody(
const EmailBody(emailId: 'acc-1:42', attachments: []), emailId: 'acc-1:42',
attachments: [],
),
), ),
), ),
], ],
@@ -384,12 +426,15 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(
emailRepositoryProvider FakeMailboxRepository(),
.overrideWithValue(FakeEmailRepository(emails: [email])), ),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
], ],
), ),
); );
+8 -6
View File
@@ -306,7 +306,9 @@ class FakeConnectionTestService implements ConnectionTestService {
class _NoOpManageSieveProbeService implements ManageSieveProbeService { class _NoOpManageSieveProbeService implements ManageSieveProbeService {
@override @override
Future<void> probe(Account account) async {/* no-op in tests */} Future<void> probe(Account account) async {
/* no-op in tests */
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -340,9 +342,8 @@ Widget buildApp({
), ),
GoRoute( GoRoute(
path: ':accountId/search', path: ':accountId/search',
builder: (ctx, state) => SearchScreen( builder: (ctx, state) =>
accountId: state.pathParameters['accountId']!, SearchScreen(accountId: state.pathParameters['accountId']!),
),
), ),
GoRoute( GoRoute(
path: ':accountId/emails/by-address/:address', path: ':accountId/emails/by-address/:address',
@@ -399,8 +400,9 @@ Widget buildApp({
// their own override before this default in [overrides]. // their own override before this default in [overrides].
overrides: [ overrides: [
...overrides, ...overrides,
manageSieveProbeServiceProvider manageSieveProbeServiceProvider.overrideWith(
.overrideWith((ref) => _NoOpManageSieveProbeService()), (ref) => _NoOpManageSieveProbeService(),
),
], ],
child: MaterialApp.router( child: MaterialApp.router(
routerConfig: testRouter, routerConfig: testRouter,
+27 -18
View File
@@ -13,10 +13,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes', initialLocation: '/accounts/acc-1/mailboxes',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository([kTestMailbox])), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -31,10 +33,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes', initialLocation: '/accounts/acc-1/mailboxes',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository([kTestMailbox])), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -45,16 +49,19 @@ void main() {
expect(find.text('3'), findsOneWidget); expect(find.text('3'), findsOneWidget);
}); });
testWidgets('tapping a mailbox tile navigates to its email list', testWidgets('tapping a mailbox tile navigates to its email list', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes', initialLocation: '/accounts/acc-1/mailboxes',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository([kTestMailbox])), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
@@ -80,10 +87,12 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes', initialLocation: '/accounts/acc-1/mailboxes',
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository([kTestAccount])), FakeAccountRepository([kTestAccount]),
mailboxRepositoryProvider ),
.overrideWithValue(FakeMailboxRepository([emptyMailbox])), mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([emptyMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
], ],
), ),
+2 -6
View File
@@ -15,18 +15,14 @@ void main() {
group('TryConnectionButton', () { group('TryConnectionButton', () {
testWidgets('shows "Try connection" button when idle', (tester) async { testWidgets('shows "Try connection" button when idle', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(const TryConnectionButton(testing: false, onPressed: null)),
const TryConnectionButton(testing: false, onPressed: null),
),
); );
expect(find.text('Try connection'), findsOneWidget); expect(find.text('Try connection'), findsOneWidget);
}); });
testWidgets('shows spinner when testing', (tester) async { testWidgets('shows spinner when testing', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(const TryConnectionButton(testing: true, onPressed: null)),
const TryConnectionButton(testing: true, onPressed: null),
),
); );
expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Try connection'), findsNothing); expect(find.text('Try connection'), findsNothing);