Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 52e6e8842a feat(D2): add task check-coverage and enforce gate in check-fast
Adds a standalone `task check-coverage` that runs unit+widget tests and
then fails if coverage drops below the 80% gate. Wires it into
`check-fast` so every pre-commit run also blocks regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 05:24:07 +02:00
Bot of Thomas Güttler 3125713e6b refactor(A2): extract shared EmailTile widget (#33) 2026-05-14 05:20:11 +02:00
Bot of Thomas Güttler 4f3a5434cc test(T4): extend migration tests to cover all schema versions up to v24 (#32) 2026-05-14 05:09:15 +02:00
Bot of Thomas Güttler 17e404407f test(T2): add widget tests for ThreadDetailScreen and SearchScreen (#31) 2026-05-14 04:58:59 +02:00
Bot of Thomas Güttler dff2b5e2ca test(T1): add edge-case coverage for EmailRepositoryImpl (#30) 2026-05-14 04:43:11 +02:00
Bot of Thomas Güttler 7096c27ede feat(U6): show sync status indicator in email list app bar (#29) 2026-05-14 04:23:07 +02:00
Bot of Thomas Güttler 2715c1613f feat(U4): background sync and local notifications for new mail (#28) 2026-05-14 04:06:35 +02:00
Bot of Thomas Güttler 0e291b509b feat(U2): sync local drafts with IMAP Drafts folder (#27) 2026-05-14 00:27:47 +02:00
Bot of Thomas Güttler 7421855922 feat(U1): show Unsubscribe chip in email detail (#26) 2026-05-14 00:09:14 +02:00
Bot of Thomas Güttler 855f9a3a6d feat(S2): validate IMAP/SMTP hostnames against injection (#25) 2026-05-13 23:49:30 +02:00
Bot of Thomas Güttler a0c35c647a test(R6): backoff stress tests for AccountSyncManager (#24) 2026-05-13 23:37:40 +02:00
Bot of Thomas Güttler fc592c475f feat(R4): dismissible sync error banner in email list (#23) 2026-05-13 23:14:44 +02:00
32 changed files with 1765 additions and 138 deletions
+8 -2
View File
@@ -331,6 +331,12 @@ tasks:
cmds: cmds:
- fvm dart run scripts/check_coverage.dart - fvm dart run scripts/check_coverage.dart
check-coverage:
desc: Run unit+widget tests with coverage, then fail if the gate is not met
deps: [test]
cmds:
- task: coverage
website-dev: website-dev:
desc: Run Hugo development server desc: Run Hugo development server
cmds: cmds:
@@ -361,8 +367,8 @@ tasks:
${SSH_USER}@${SSH_HOST}:public_html/ ${SSH_USER}@${SSH_HOST}:public_html/
check-fast: check-fast:
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration) desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, test, check-hygiene] deps: [analyze, check-coverage, 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
+1 -1
View File
@@ -35,7 +35,7 @@ android {
applicationId = "de.sharedinbox.mua" applicationId = "de.sharedinbox.mua"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 23
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
+3
View File
@@ -1,5 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application <application
android:label="sharedinbox" android:label="sharedinbox"
android:name="${applicationName}" android:name="${applicationName}"
+2
View File
@@ -7,6 +7,7 @@ class SavedDraft {
final String subjectText; final String subjectText;
final String bodyText; final String bodyText;
final DateTime updatedAt; final DateTime updatedAt;
final String? imapServerId;
const SavedDraft({ const SavedDraft({
required this.id, required this.id,
@@ -17,5 +18,6 @@ class SavedDraft {
required this.subjectText, required this.subjectText,
required this.bodyText, required this.bodyText,
required this.updatedAt, required this.updatedAt,
this.imapServerId,
}); });
} }
+8
View File
@@ -21,6 +21,8 @@ class Email {
final String? references; final String? references;
final DateTime? snoozedUntil; final DateTime? snoozedUntil;
final String? snoozedFromMailboxPath; final String? snoozedFromMailboxPath;
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
final String? listUnsubscribeHeader;
const Email({ const Email({
required this.id, required this.id,
@@ -43,6 +45,7 @@ class Email {
this.references, this.references,
this.snoozedUntil, this.snoozedUntil,
this.snoozedFromMailboxPath, this.snoozedFromMailboxPath,
this.listUnsubscribeHeader,
}); });
factory Email.fromJson(Map<String, dynamic> json) { factory Email.fromJson(Map<String, dynamic> json) {
@@ -77,6 +80,7 @@ class Email {
? DateTime.parse(json['snoozedUntil'] as String) ? DateTime.parse(json['snoozedUntil'] as String)
: null, : null,
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?, snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
); );
} }
@@ -102,6 +106,7 @@ class Email {
'references': references, 'references': references,
'snoozedUntil': snoozedUntil?.toIso8601String(), 'snoozedUntil': snoozedUntil?.toIso8601String(),
'snoozedFromMailboxPath': snoozedFromMailboxPath, 'snoozedFromMailboxPath': snoozedFromMailboxPath,
'listUnsubscribeHeader': listUnsubscribeHeader,
}; };
} }
@@ -126,6 +131,7 @@ class Email {
String? references, String? references,
DateTime? snoozedUntil, DateTime? snoozedUntil,
String? snoozedFromMailboxPath, String? snoozedFromMailboxPath,
String? listUnsubscribeHeader,
}) { }) {
return Email( return Email(
id: id ?? this.id, id: id ?? this.id,
@@ -149,6 +155,8 @@ class Email {
snoozedUntil: snoozedUntil ?? this.snoozedUntil, snoozedUntil: snoozedUntil ?? this.snoozedUntil,
snoozedFromMailboxPath: snoozedFromMailboxPath:
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath, snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
listUnsubscribeHeader:
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
); );
} }
} }
@@ -21,4 +21,10 @@ abstract class DraftRepository {
/// Permanently removes the draft with [id]. /// Permanently removes the draft with [id].
Future<void> deleteDraft(int id); Future<void> deleteDraft(int id);
/// Syncs local drafts with the server IMAP Drafts folder for [accountId].
/// Uploads local drafts that have no [SavedDraft.imapServerId]; imports
/// server drafts that are not already tracked locally.
/// No-op when the implementation has no IMAP connection configured.
Future<void> syncDrafts(String accountId, String password);
} }
@@ -0,0 +1,38 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
const _kChannelId = 'new_mail';
const _kChannelName = 'New mail';
final _plugin = FlutterLocalNotificationsPlugin();
Future<void> initNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
const InitializationSettings(android: android),
onDidReceiveNotificationResponse: (_) {},
);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
}
Future<void> showNewMailNotification(String accountEmail) async {
if (!Platform.isAndroid) return;
await _plugin.show(
accountEmail.hashCode & 0x7FFFFFFF,
'New mail',
accountEmail,
const NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
channelDescription: 'Notifications for new incoming mail',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
+69 -5
View File
@@ -4,6 +4,7 @@ import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult; import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
@@ -11,6 +12,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart' import 'package:sharedinbox/data/imap/imap_client_factory.dart'
show ImapConnectFn, connectImap, verboseLogKey; show ImapConnectFn, connectImap, verboseLogKey;
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
/// Manages background sync for all accounts. /// Manages background sync for all accounts.
/// ///
/// IMAP accounts get an IDLE-based sync loop (_AccountSync). /// IMAP accounts get an IDLE-based sync loop (_AccountSync).
@@ -22,19 +25,35 @@ class AccountSyncManager {
this._emails, { this._emails, {
ImapConnectFn imapConnect = connectImap, ImapConnectFn imapConnect = connectImap,
SyncLogRepository syncLog = const NoOpSyncLogRepository(), SyncLogRepository syncLog = const NoOpSyncLogRepository(),
DraftRepository? drafts,
OnNewMailCallback? onNewMail,
}) : _imapConnect = imapConnect, }) : _imapConnect = imapConnect,
_syncLog = syncLog; _syncLog = syncLog,
_drafts = drafts,
_onNewMail = onNewMail;
final AccountRepository _accounts; final AccountRepository _accounts;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final ImapConnectFn _imapConnect; final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
final Map<String, _SyncLoop> _active = {}; final Map<String, _SyncLoop> _active = {};
StreamSubscription<List<Account>>? _accountsSub; StreamSubscription<List<Account>>? _accountsSub;
StreamSubscription<String>? _onChangesSub; StreamSubscription<String>? _onChangesSub;
final _syncPhaseCtrl = StreamController<(String, bool)>.broadcast();
/// Emits `true` when [accountId] starts syncing, `false` when it stops.
Stream<bool> watchSyncing(String accountId) =>
_syncPhaseCtrl.stream.where((e) => e.$1 == accountId).map((e) => e.$2);
void _emitSyncing(String accountId, {required bool syncing}) {
if (!_syncPhaseCtrl.isClosed) _syncPhaseCtrl.add((accountId, syncing));
}
void start() { void start() {
_onChangesSub = _emails.onChangesQueued.listen((accountId) { _onChangesSub = _emails.onChangesQueued.listen((accountId) {
_active[accountId]?.kick(); _active[accountId]?.kick();
@@ -45,6 +64,7 @@ class AccountSyncManager {
for (final account in accounts) { for (final account in accounts) {
if (_active.containsKey(account.id)) continue; if (_active.containsKey(account.id)) continue;
final id = account.id;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
@@ -53,6 +73,10 @@ class AccountSyncManager {
_emails, _emails,
_imapConnect, _imapConnect,
_syncLog, _syncLog,
_drafts,
_onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -60,6 +84,8 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
}; };
_active[account.id] = loop; _active[account.id] = loop;
@@ -81,6 +107,7 @@ class AccountSyncManager {
s.stop(); s.stop();
} }
_active.clear(); _active.clear();
unawaited(_syncPhaseCtrl.close());
} }
/// Wakes the idle/wait phase of the given account's sync loop so a new /// Wakes the idle/wait phase of the given account's sync loop so a new
@@ -113,6 +140,10 @@ class AccountSyncManager {
_emails, _emails,
_imapConnect, _imapConnect,
_syncLog, _syncLog,
_drafts,
_onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -120,6 +151,8 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
}; };
_active[accountId] = loop; _active[accountId] = loop;
@@ -145,7 +178,12 @@ class _AccountSync implements _SyncLoop {
this._emails, this._emails,
this._imapConnect, this._imapConnect,
this._syncLog, this._syncLog,
); this._drafts,
this._onNewMail, {
void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -153,6 +191,10 @@ class _AccountSync implements _SyncLoop {
final EmailRepository _emails; final EmailRepository _emails;
final ImapConnectFn _imapConnect; final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
imap.ImapClient? _idleClient; imap.ImapClient? _idleClient;
bool _running = false; bool _running = false;
@@ -185,6 +227,7 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -204,8 +247,10 @@ class _AccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _idle(); await _idle();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
@@ -279,6 +324,8 @@ class _AccountSync implements _SyncLoop {
Future<_SyncStats> _sync() async { Future<_SyncStats> _sync() async {
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
await _drafts?.syncDrafts(account.id, password);
// 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);
@@ -325,6 +372,7 @@ class _AccountSync implements _SyncLoop {
await client.selectMailboxByPath('INBOX'); await client.selectMailboxByPath('INBOX');
final newMessageCompleter = Completer<void>(); final newMessageCompleter = Completer<void>();
var hasNewMail = false;
final sub = client.eventBus final sub = client.eventBus
.on<imap.ImapEvent>() .on<imap.ImapEvent>()
@@ -332,7 +380,11 @@ class _AccountSync implements _SyncLoop {
(e) => (e) =>
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent, e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
) )
.listen((_) { .listen((e) {
if (e is imap.ImapMessagesExistEvent &&
e.newMessagesExists > e.oldMessagesExists) {
hasNewMail = true;
}
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete(); if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
}); });
@@ -348,6 +400,10 @@ class _AccountSync implements _SyncLoop {
await client.idleDone(); await client.idleDone();
await sub.cancel(); await sub.cancel();
if (hasNewMail) {
unawaited(_onNewMail?.call(account.email));
}
} finally { } finally {
await client.logout(); await client.logout();
_idleClient = null; _idleClient = null;
@@ -364,14 +420,19 @@ class _JmapAccountSync implements _SyncLoop {
this._mailboxes, this._mailboxes,
this._emails, this._emails,
this._accounts, this._accounts,
this._syncLog, this._syncLog, {
); void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final AccountRepository _accounts; final AccountRepository _accounts;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
bool _running = false; bool _running = false;
int _backoffSeconds = 5; int _backoffSeconds = 5;
@@ -403,6 +464,7 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -422,8 +484,10 @@ class _JmapAccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _wait(); await _wait();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
+123
View File
@@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
import 'package:workmanager/workmanager.dart';
const _kTaskName = 'si_bg_sync';
const _kResourceType = 'background_check';
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((_, __) async {
try {
await _doBackgroundSync();
} catch (_) {}
return true;
});
}
Future<void> registerBackgroundSync() async {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
_kTaskName,
_kTaskName,
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.keep,
);
}
Future<void> _doBackgroundSync() async {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
);
try {
final accountRepo = AccountRepositoryImpl(
db,
const FlutterSecureStorageImpl(),
);
final accounts = await accountRepo.observeAccounts().first;
await initNotifications();
for (final account in accounts) {
if (account.type != model.AccountType.imap) continue;
await _checkAccount(db, accountRepo, account);
}
} finally {
await db.close();
}
}
Future<void> _checkAccount(
AppDatabase db,
AccountRepository accountRepo,
model.Account account,
) async {
try {
final password = await accountRepo.getPassword(account.id);
final username =
account.username.isNotEmpty ? account.username : account.email;
final client = await connectImap(account, username, password);
try {
final status = await client.statusMailbox(
imap.Mailbox.virtual('INBOX', []),
[imap.StatusFlags.uidNext],
);
final currentUidNext = status.uidNext;
final stored = await (db.select(db.syncStates)
..where(
(t) =>
t.accountId.equals(account.id) &
t.resourceType.equals(_kResourceType),
))
.getSingleOrNull();
final lastUidNext = _parseUidNext(stored?.state);
await db.into(db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: account.id,
resourceType: _kResourceType,
state: jsonEncode({'uidNext': currentUidNext}),
syncedAt: DateTime.now(),
),
);
if (lastUidNext != null &&
currentUidNext != null &&
currentUidNext > lastUidNext) {
await showNewMailNotification(account.email);
}
} finally {
await client.logout();
}
} catch (_) {}
}
int? _parseUidNext(String? state) {
if (state == null) return null;
try {
final decoded = jsonDecode(state);
if (decoded is Map<String, Object?>) {
return decoded['uidNext'] as int?;
}
return null;
} catch (_) {
return null;
}
}
+18
View File
@@ -2,3 +2,21 @@ bool isLocalhost(String host) {
final h = host.trim().toLowerCase(); final h = host.trim().toLowerCase();
return h == 'localhost' || h == '127.0.0.1' || h == '::1'; return h == 'localhost' || h == '127.0.0.1' || h == '::1';
} }
String? validateHostname(String? value) {
if (value == null || value.trim().isEmpty) return 'Required';
return _checkHostChars(value.trim());
}
String? validateOptionalHostname(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _checkHostChars(value.trim());
}
String? _checkHostChars(String h) {
if (h.contains(RegExp(r'[@/\\]')) ||
h.codeUnits.any((c) => c < 32 || c == 127)) {
return 'Invalid hostname';
}
return null;
}
+12 -1
View File
@@ -88,6 +88,9 @@ class Emails extends Table {
DateTimeColumn get snoozedUntil => dateTime().nullable()(); DateTimeColumn get snoozedUntil => dateTime().nullable()();
TextColumn get snoozedFromMailboxPath => text().nullable()(); TextColumn get snoozedFromMailboxPath => text().nullable()();
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
TextColumn get listUnsubscribeHeader => text().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -227,6 +230,8 @@ class Drafts extends Table {
TextColumn get subjectText => text().withDefault(const Constant(''))(); TextColumn get subjectText => text().withDefault(const Constant(''))();
TextColumn get bodyText => text().withDefault(const Constant(''))(); TextColumn get bodyText => text().withDefault(const Constant(''))();
DateTimeColumn get updatedAt => dateTime()(); DateTimeColumn get updatedAt => dateTime()();
// Added in schema v24: IMAP UID string ("mailbox:uid") on the server.
TextColumn get imapServerId => text().nullable()();
} }
@DataClassName('UndoActionRow') @DataClassName('UndoActionRow')
@@ -264,7 +269,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 22; int get schemaVersion => 24;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -420,6 +425,12 @@ class AppDatabase extends _$AppDatabase {
), ),
); );
} }
if (from < 23) {
await m.addColumn(emails, emails.listUnsubscribeHeader);
}
if (from >= 4 && from < 24) {
await m.addColumn(drafts, drafts.imapServerId);
}
}, },
); );
} }
@@ -1,13 +1,23 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/draft.dart'; import 'package:sharedinbox/core/models/draft.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db); DraftRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn? _imapConnect;
@override @override
Future<SavedDraft> saveDraft({ Future<SavedDraft> saveDraft({
@@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository {
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go(); await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
} }
@override
Future<void> syncDrafts(String accountId, String password) async {
final connect = _imapConnect;
if (connect == null) return;
final account = await _accounts.getAccount(accountId);
if (account == null || account.type != AccountType.imap) return;
final username =
account.username.isNotEmpty ? account.username : account.email;
imap.ImapClient? client;
try {
client = await connect(account, username, password);
await _syncWithServer(client, accountId);
} finally {
await client?.logout();
}
}
Future<void> _syncWithServer(
imap.ImapClient client,
String accountId,
) async {
// Create/select the Drafts folder.
try {
await client.createMailbox('Drafts');
} catch (_) {
// Already exists.
}
final selectResult = await client.selectMailboxByPath('Drafts');
final messageCount = selectResult.messagesExists;
// Upload local drafts that have no server counterpart.
final localDrafts = await (_db.select(_db.drafts)
..where(
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
))
.get();
for (final row in localDrafts) {
final builder = imap.MessageBuilder()
..to = _parseAddresses(row.toText)
..cc = _parseAddresses(row.ccText)
..subject = row.subjectText
..text = row.bodyText;
final mime = builder.buildMimeMessage();
final appendResult = await client.appendMessage(
mime,
targetMailboxPath: 'Drafts',
flags: [r'\Draft'],
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final uid = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: null;
if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
.write(DraftsCompanion(imapServerId: Value(uid)));
}
}
// Download server drafts not tracked locally.
if (messageCount > 0) {
final knownServerIds = await (_db.select(_db.drafts)
..where(
(t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
))
.get();
final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet();
final seq = imap.MessageSequence.fromAll();
final fetch = await client.uidFetchMessages(seq, '(UID FLAGS ENVELOPE)');
for (final msg in fetch.messages) {
final uid = msg.uid?.toString();
if (uid == null || knownIds.contains(uid)) continue;
if (msg.flags?.contains(r'\Deleted') ?? false) continue;
final env = msg.envelope;
final now = DateTime.now();
await _db.into(_db.drafts).insert(
DraftsCompanion.insert(
accountId: Value(accountId),
toText: Value(_addressListToText(env?.to)),
ccText: Value(_addressListToText(env?.cc)),
subjectText: Value(env?.subject ?? ''),
bodyText: const Value(''),
updatedAt: now,
imapServerId: Value(uid),
),
);
}
}
}
List<imap.MailAddress> _parseAddresses(String text) {
if (text.trim().isEmpty) return [];
return text.split(',').map((s) => imap.MailAddress('', s.trim())).toList();
}
String _addressListToText(List<imap.MailAddress>? addresses) {
if (addresses == null || addresses.isEmpty) return '';
return addresses.map((a) => a.email).join(', ');
}
SavedDraft _toModel(Draft row) => SavedDraft( SavedDraft _toModel(Draft row) => SavedDraft(
id: row.id, id: row.id,
accountId: row.accountId, accountId: row.accountId,
@@ -104,5 +218,6 @@ class DraftRepositoryImpl implements DraftRepository {
subjectText: row.subjectText, subjectText: row.subjectText,
bodyText: row.bodyText, bodyText: row.bodyText,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
imapServerId: row.imapServerId,
); );
} }
@@ -528,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository {
imap.MessageSequence sequence, imap.MessageSequence sequence,
) async { ) async {
const fetchItems = const fetchItems =
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])'; '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES LIST-UNSUBSCRIBE)])';
final fetch = sequence.isUidSequence final fetch = sequence.isUidSequence
? await client.uidFetchMessages(sequence, fetchItems) ? await client.uidFetchMessages(sequence, fetchItems)
: await client.fetchMessages(sequence, fetchItems); : await client.fetchMessages(sequence, fetchItems);
@@ -569,6 +569,7 @@ class EmailRepositoryImpl implements EmailRepository {
final msgId = envelope.messageId?.trim(); final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim(); final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim(); final refs = msg.getHeaderValue('References')?.trim();
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
final threadId = _computeThreadId( final threadId = _computeThreadId(
emailId: emailId, emailId: emailId,
messageId: msgId, messageId: msgId,
@@ -612,6 +613,7 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(inReplyTo), inReplyTo: Value(inReplyTo),
references: Value(refs), references: Value(refs),
snoozedUntil: Value(snoozedUntil), snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(listUnsubscribe),
), ),
); );
} }
@@ -950,6 +952,7 @@ class EmailRepositoryImpl implements EmailRepository {
'htmlBody', 'htmlBody',
'bodyValues', 'bodyValues',
'attachments', 'attachments',
'header:List-Unsubscribe:asText',
]; ];
static const _emailGetBodyOptions = { static const _emailGetBodyOptions = {
@@ -1151,6 +1154,8 @@ class EmailRepositoryImpl implements EmailRepository {
final jmapReferences = _joinJmapStringList( final jmapReferences = _joinJmapStringList(
m['references'] as List<dynamic>?, m['references'] as List<dynamic>?,
); );
final jmapListUnsubscribe =
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
await _db.into(_db.emails).insertOnConflictUpdate( await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert( EmailsCompanion.insert(
@@ -1173,6 +1178,7 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(jmapInReplyTo), inReplyTo: Value(jmapInReplyTo),
references: Value(jmapReferences), references: Value(jmapReferences),
snoozedUntil: Value(snoozedUntil), snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(jmapListUnsubscribe),
), ),
); );
@@ -2663,6 +2669,7 @@ class EmailRepositoryImpl implements EmailRepository {
references: row.references, references: row.references,
snoozedUntil: row.snoozedUntil, snoozedUntil: row.snoozedUntil,
snoozedFromMailboxPath: row.snoozedFromMailboxPath, snoozedFromMailboxPath: row.snoozedFromMailboxPath,
listUnsubscribeHeader: row.listUnsubscribeHeader,
); );
} }
+13 -1
View File
@@ -12,6 +12,7 @@ import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/services/undo_service.dart'; import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart';
@@ -65,7 +66,11 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
}); });
final draftRepositoryProvider = Provider<DraftRepository>((ref) { final draftRepositoryProvider = Provider<DraftRepository>((ref) {
return DraftRepositoryImpl(ref.watch(dbProvider)); return DraftRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
}); });
final emailRepositoryProvider = Provider<EmailRepository>((ref) { final emailRepositoryProvider = Provider<EmailRepository>((ref) {
@@ -110,6 +115,11 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider =
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) { final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager( final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider), ref.watch(accountRepositoryProvider),
@@ -117,6 +127,8 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
ref.watch(emailRepositoryProvider), ref.watch(emailRepositoryProvider),
syncLog: ref.watch(syncLogRepositoryProvider), syncLog: ref.watch(syncLogRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider), imapConnect: ref.watch(imapConnectProvider),
drafts: ref.watch(draftRepositoryProvider),
onNewMail: showNewMailNotification,
); );
ref.onDispose(manager.dispose); ref.onDispose(manager.dispose);
return manager; return manager;
+7
View File
@@ -1,8 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/router.dart'; import 'package:sharedinbox/ui/router.dart';
@@ -32,6 +35,10 @@ void main({List<Override> overrides = const []}) async {
}; };
await initDatabasePath(); await initDatabasePath();
if (Platform.isAndroid) {
await initNotifications();
await registerBackgroundSync();
}
runApp( runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()), ProviderScope(overrides: overrides, child: const SharedInboxApp()),
); );
+7 -5
View File
@@ -408,7 +408,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
_field(_passwordCtrl, 'Password', obscure: true), _field(_passwordCtrl, 'Password', obscure: true),
const Divider(height: 32), const Divider(height: 32),
Text('IMAP', style: Theme.of(context).textTheme.titleSmall), Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'), _field(_imapHostCtrl, 'Host', validator: validateHostname),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_imapHostCtrl.text.trim())) if (isLocalhost(_imapHostCtrl.text.trim()))
SwitchListTile( SwitchListTile(
@@ -418,7 +418,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
), ),
const Divider(height: 32), const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall), Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'), _field(_smtpHostCtrl, 'Host', validator: validateHostname),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_smtpHostCtrl.text.trim())) if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile( SwitchListTile(
@@ -475,6 +475,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
bool obscure = false, bool obscure = false,
bool required = true, bool required = true,
TextInputType? keyboardType, TextInputType? keyboardType,
String? Function(String?)? validator,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
@@ -486,9 +487,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
validator: required validator: validator ??
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null (required
: null, ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null),
), ),
); );
} }
+8 -5
View File
@@ -324,11 +324,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
'IMAP (SSL/TLS)', 'IMAP (SSL/TLS)',
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
_field(_imapHostCtrl, 'Host'), _field(_imapHostCtrl, 'Host', validator: validateHostname),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
const Divider(height: 32), const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall), Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'), _field(_smtpHostCtrl, 'Host', validator: validateHostname),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number), _field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_smtpHostCtrl.text.trim())) if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile( SwitchListTile(
@@ -348,6 +348,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_sieveHostCtrl, _sieveHostCtrl,
'Host (leave blank to use IMAP host)', 'Host (leave blank to use IMAP host)',
required: false, required: false,
validator: validateOptionalHostname,
), ),
_field( _field(
_sievePortCtrl, _sievePortCtrl,
@@ -408,6 +409,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
bool obscure = false, bool obscure = false,
bool required = true, bool required = true,
TextInputType? keyboardType, TextInputType? keyboardType,
String? Function(String?)? validator,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
@@ -420,9 +422,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
labelText: label, labelText: label,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
validator: required validator: validator ??
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null (required
: null, ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null),
), ),
); );
} }
+39
View File
@@ -13,6 +13,7 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm'); final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
@@ -267,6 +268,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_dateFmt.format(email.sentAt!), _dateFmt.format(email.sentAt!),
style: Theme.of(ctx).textTheme.bodySmall, style: Theme.of(ctx).textTheme.bodySmall,
), ),
if (email.listUnsubscribeHeader != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _UnsubscribeChip(header: email.listUnsubscribeHeader!),
),
], ],
); );
} }
@@ -462,6 +468,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
} }
/// Parses a List-Unsubscribe header and returns the first usable URI.
/// Prefers mailto: so unsubscribing sends an email; falls back to https:.
Uri? _parseUnsubscribeUri(String header) {
final matches = RegExp(r'<([^>]+)>').allMatches(header);
Uri? fallback;
for (final m in matches) {
final raw = m.group(1)!.trim();
final uri = Uri.tryParse(raw);
if (uri == null) continue;
if (uri.scheme == 'mailto') return uri;
if ((uri.scheme == 'https' || uri.scheme == 'http') && fallback == null) {
fallback = uri;
}
}
return fallback;
}
class _UnsubscribeChip extends StatelessWidget {
const _UnsubscribeChip({required this.header});
final String header;
@override
Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink();
return ActionChip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
);
}
}
class _BlockRemoteImagesExtension extends HtmlExtension { class _BlockRemoteImagesExtension extends HtmlExtension {
@override @override
Set<String> get supportedTags => {'img'}; Set<String> get supportedTags => {'img'};
+44 -39
View File
@@ -10,6 +10,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -180,22 +181,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
), ),
), ),
), ),
IconButton( _buildSyncButton(emailRepo),
icon: const Icon(Icons.sync),
onPressed: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
}
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => context.push( onPressed: () => context.push(
@@ -229,6 +215,44 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
: hasError
? 'Sync error'
: 'Sync',
icon: isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: hasError
? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
}
},
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
@@ -688,10 +712,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final e = emails[i]; final e = emails[i];
final isSelected = _selectedSearchIds.contains(e.id); final isSelected = _selectedSearchIds.contains(e.id);
final sender = e.from.isNotEmpty return EmailTile(
? (e.from.first.name ?? e.from.first.email) email: e,
: '(unknown)'; selected: isSelected,
return ListTile(
leading: SizedBox( leading: SizedBox(
width: 40, width: 40,
child: _selecting child: _selecting
@@ -699,25 +722,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
value: isSelected, value: isSelected,
onChanged: (_) => _toggleSearchSelection(e.id), onChanged: (_) => _toggleSearchSelection(e.id),
) )
: Icon( : null,
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
),
),
title: Text(
sender,
style:
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
selected: isSelected,
trailing: Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
), ),
onTap: _selecting onTap: _selecting
? () => _toggleSearchSelection(e.id) ? () => _toggleSearchSelection(e.id)
+10 -40
View File
@@ -8,6 +8,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
class SearchScreen extends ConsumerStatefulWidget { class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key, this.accountId}); const SearchScreen({super.key, this.accountId});
@@ -155,7 +156,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[ if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'), const _SectionHeader('Messages'),
for (final e in r.emails) for (final e in r.emails)
_EmailTile(email: e, accountId: e.accountId), EmailTile(
email: e,
showLocation: true,
onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}'
'/emails/${Uri.encodeComponent(e.id)}',
),
),
], ],
], ],
); );
@@ -246,42 +255,3 @@ class _AddressTile extends StatelessWidget {
); );
} }
} }
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email, required this.accountId});
final Email email;
final String accountId;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(unknown)';
return ListTile(
leading: Icon(
email.isSeen ? Icons.mail_outline : Icons.mail,
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
),
title: Text(sender),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
email.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'$accountId${email.mailboxPath}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
onTap: () => context.push(
'/accounts/$accountId/mailboxes'
'/${Uri.encodeComponent(email.mailboxPath)}'
'/emails/${Uri.encodeComponent(email.id)}',
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
/// A flat list tile for an individual [email].
///
/// Used in search-result lists and the per-mailbox search overlay.
/// Pass a custom [leading] widget to support selection-mode checkboxes.
class EmailTile extends StatelessWidget {
const EmailTile({
super.key,
required this.email,
required this.onTap,
this.leading,
this.selected = false,
this.onLongPress,
this.showLocation = false,
});
final Email email;
final VoidCallback onTap;
final Widget? leading;
final bool selected;
final VoidCallback? onLongPress;
/// When true, appends `accountId • mailboxPath` as a second subtitle line.
final bool showLocation;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(unknown)';
final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : '';
return ListTile(
leading: leading ??
Icon(
email.isSeen ? Icons.mail_outline : Icons.mail,
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
),
title: Text(
sender,
style:
email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
email.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (showLocation)
Text(
'${email.accountId}${email.mailboxPath}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
trailing: date.isEmpty
? null
: Text(date, style: Theme.of(context).textTheme.bodySmall),
selected: selected,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
+4
View File
@@ -45,6 +45,10 @@ dependencies:
url_launcher: ^6.3.2 url_launcher: ^6.3.2
flutter_markdown: ^0.7.7+1 flutter_markdown: ^0.7.7+1
# Background sync and local notifications
flutter_local_notifications: ^18.0.1
workmanager: ^0.5.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+1
View File
@@ -52,6 +52,7 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart', 'lib/ui/widgets/undo_shell.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart',
'lib/core/sync/reliability_runner.dart', 'lib/core/sync/reliability_runner.dart',
'lib/data/jmap/jmap_client.dart', 'lib/data/jmap/jmap_client.dart',
'lib/data/jmap/sieve_repository.dart', 'lib/data/jmap/sieve_repository.dart',
@@ -187,6 +187,16 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
), ),
returnValue: _i4.Future<_i8.Mailbox?>.value(), returnValue: _i4.Future<_i8.Mailbox?>.value(),
) as _i4.Future<_i8.Mailbox?>); ) as _i4.Future<_i8.Mailbox?>);
@override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
} }
/// A class which mocks [EmailRepository]. /// A class which mocks [EmailRepository].
@@ -582,4 +592,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
), ),
)), )),
) as _i4.Future<_i2.ReliabilityResult>); ) as _i4.Future<_i2.ReliabilityResult>);
@override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
} }
+24 -8
View File
@@ -1,9 +1,25 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart'; import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
import 'db_test_helper.dart'; import 'db_test_helper.dart';
class _StubAccounts implements AccountRepository {
@override
Stream<List<Account>> observeAccounts() => const Stream.empty();
@override
Future<Account?> getAccount(String id) async => null;
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
@override
Future<String> getPassword(String accountId) async => '';
}
void main() { void main() {
setUpAll(configureSqliteForTests); setUpAll(configureSqliteForTests);
@@ -11,7 +27,7 @@ void main() {
test( test(
'saveDraft creates a new row and returns it with a non-zero id', 'saveDraft creates a new row and returns it with a non-zero id',
() async { () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
final draft = await repo.saveDraft( final draft = await repo.saveDraft(
toText: 'bob@example.com', toText: 'bob@example.com',
ccText: '', ccText: '',
@@ -25,7 +41,7 @@ void main() {
); );
test('saveDraft with id updates existing row', () async { test('saveDraft with id updates existing row', () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
final created = await repo.saveDraft( final created = await repo.saveDraft(
toText: 'a@example.com', toText: 'a@example.com',
ccText: '', ccText: '',
@@ -47,19 +63,19 @@ void main() {
}); });
test('getDraft returns null for unknown id', () async { test('getDraft returns null for unknown id', () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
expect(await repo.getDraft(99999), isNull); expect(await repo.getDraft(99999), isNull);
}); });
test('findDraft returns null when no draft exists', () async { test('findDraft returns null when no draft exists', () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
expect(await repo.findDraft(), isNull); expect(await repo.findDraft(), isNull);
}); });
test( test(
'findDraft returns most recent draft for matching replyToEmailId', 'findDraft returns most recent draft for matching replyToEmailId',
() async { () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
await repo.saveDraft( await repo.saveDraft(
replyToEmailId: 'email-1', replyToEmailId: 'email-1',
toText: 'a@example.com', toText: 'a@example.com',
@@ -83,7 +99,7 @@ void main() {
test( test(
'findDraft with null replyToEmailId finds new-message drafts', 'findDraft with null replyToEmailId finds new-message drafts',
() async { () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
// This draft is a reply and should NOT be returned. // This draft is a reply and should NOT be returned.
await repo.saveDraft( await repo.saveDraft(
replyToEmailId: 'email-1', replyToEmailId: 'email-1',
@@ -104,7 +120,7 @@ void main() {
); );
test('deleteDraft removes the row', () async { test('deleteDraft removes the row', () async {
final repo = DraftRepositoryImpl(openTestDatabase()); final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
final draft = await repo.saveDraft( final draft = await repo.saveDraft(
toText: 'a@example.com', toText: 'a@example.com',
ccText: '', ccText: '',
+166 -4
View File
@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -16,6 +16,7 @@ import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage; import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart'; import 'db_test_helper.dart';
import 'fake_imap.dart' show FakeImapClient;
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account( const _account = Account(
@@ -162,15 +163,19 @@ Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('SMTP unavailable in unit tests')); Future.error(UnsupportedError('SMTP unavailable in unit tests'));
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
_makeRepos({http.Client? httpClient}) { _makeRepos({
http.Client? httpClient,
Future<imap.ImapClient> Function(Account, String, String)? imapConnect,
Future<imap.SmtpClient> Function(Account, String, String)? smtpConnect,
}) {
final db = openTestDatabase(); final db = openTestDatabase();
final storage = MapSecureStorage(); final storage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, storage); final accounts = AccountRepositoryImpl(db, storage);
final emails = EmailRepositoryImpl( final emails = EmailRepositoryImpl(
db, db,
accounts, accounts,
imapConnect: _noImapConnect, imapConnect: imapConnect ?? _noImapConnect,
smtpConnect: _noSmtpConnect, smtpConnect: smtpConnect ?? _noSmtpConnect,
httpClient: httpClient, httpClient: httpClient,
); );
return (db: db, accounts: accounts, emails: emails); return (db: db, accounts: accounts, emails: emails);
@@ -1935,6 +1940,163 @@ void main() {
expect(row.lastError, isNull); expect(row.lastError, isNull);
}); });
}); });
group('concurrent moves', () {
test(
'two simultaneous moves enqueue two changes and leave email in last destination',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 5,
receivedAt: DateTime(2024),
),
);
// Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5');
expect(
email!.mailboxPath,
anyOf('Archive', 'Trash'),
reason:
'email must be optimistically moved to one of the two destinations',
);
});
});
group('IMAP SMTP auth failure', () {
test('sendEmail propagates SMTP authentication error', () async {
final r = _makeRepos(
smtpConnect: (Account _, String __, String ___) => Future.error(
Exception('535 5.7.8 Authentication credentials invalid'),
),
);
await r.accounts.addAccount(_account, 'pw');
const draft = EmailDraft(
from: EmailAddress(name: 'Alice', email: 'alice@example.com'),
to: [EmailAddress(name: 'Bob', email: 'bob@example.com')],
cc: [],
subject: 'Test',
body: 'Body',
);
await expectLater(
r.emails.sendEmail('acc-1', draft),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('535'),
),
),
);
});
});
group('IMAP UID validity change', () {
test('full re-sync wipes stale emails when uidValidity changes', () async {
final r = _makeRepos(
imapConnect: (Account _, String __, String ___) async =>
_FakeImapClientUidValidity(456),
);
await r.accounts.addAccount(_account, 'pw');
// Pre-seed two emails from the old server epoch (uidValidity=123).
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
receivedAt: DateTime(2024),
),
);
// Seed an IMAP checkpoint with the old uidValidity so the code detects
// a mismatch and triggers a full re-sync.
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'acc-1',
resourceType: 'IMAP:INBOX',
state: '{"uidValidity":123,"lastUid":2,"highestModSeq":null}',
syncedAt: DateTime(2024),
),
);
await r.emails.syncEmails('acc-1', 'INBOX');
// Old emails must be wiped; the fake server returns zero messages.
final remaining = await r.db.select(r.db.emails).get();
expect(remaining, isEmpty);
// Checkpoint must be updated to the new uidValidity.
final stateRow = await (r.db.select(r.db.syncStates)
..where(
(t) =>
t.accountId.equals('acc-1') &
t.resourceType.equals('IMAP:INBOX'),
))
.getSingleOrNull();
expect(stateRow, isNotNull);
final state = jsonDecode(stateRow!.state) as Map<String, dynamic>;
expect(state['uidValidity'], 456);
});
});
}
// ── Additional fake IMAP client for UID-validity tests ───────────────────────
class _FakeImapClientUidValidity extends FakeImapClient {
_FakeImapClientUidValidity(this._uidValidity);
final int _uidValidity;
@override
Future<imap.Mailbox> selectMailboxByPath(
String path, {
bool enableCondStore = false,
imap.QResyncParameters? qresync,
}) async =>
imap.Mailbox(
encodedName: path,
encodedPath: path,
flags: [],
pathSeparator: '/',
uidValidity: _uidValidity,
);
@override
Future<imap.SearchImapResult> uidSearchMessages({
String searchCriteria = 'ALL',
List<imap.ReturnOption>? returnOptions,
Duration? responseTimeout,
}) async =>
imap.SearchImapResult();
} }
// ── SSE test helper ────────────────────────────────────────────────────────── // ── SSE test helper ──────────────────────────────────────────────────────────
+182 -25
View File
@@ -4,11 +4,22 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite; import 'package:sqlite3/sqlite3.dart' as sqlite;
/// Reads all column names for [tableName] from [db].
Future<List<String>> _tableColumns(AppDatabase db, String tableName) async {
final rows = await db.customSelect('PRAGMA table_info($tableName)').get();
return rows.map((r) => r.read<String>('name')).toList();
}
void main() { void main() {
group('Migration', () { group('Migration', () {
test('upgrade from v1 to latest', () async { test('schemaVersion matches expected value', () async {
// 1. Create a V1 database using raw sqlite3. final db = AppDatabase(NativeDatabase.memory());
final dbFile = File('test_migration.db'); expect(db.schemaVersion, 24);
await db.close();
});
test('upgrade from v1 to latest checks all added columns', () async {
final dbFile = File('test_migration_v1.db');
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
final rawDb = sqlite.sqlite3.open(dbFile.path); final rawDb = sqlite.sqlite3.open(dbFile.path);
@@ -67,41 +78,187 @@ void main() {
rawDb.execute('PRAGMA user_version = 1;'); rawDb.execute('PRAGMA user_version = 1;');
rawDb.close(); rawDb.close();
// 2. Open it with AppDatabase (v22).
final db = AppDatabase(NativeDatabase(dbFile)); final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration by performing a simple query. // Trigger migration by performing a query.
final accs = await db.select(db.accounts).get(); final accs = await db.select(db.accounts).get();
expect(accs, hasLength(1)); expect(accs, hasLength(1));
expect(accs.first.displayName, 'Alice'); expect(accs.first.displayName, 'Alice');
expect(accs.first.accountType, 'imap'); // default value expect(accs.first.accountType, 'imap');
// 3. Verify that all columns exist. // v2v3: accounts columns.
// If migration failed, it would have thrown an exception during opening or query. final accountColumns = await _tableColumns(db, 'accounts');
final tableInfo = expect(
await db.customSelect('PRAGMA table_info(emails)').get(); accountColumns,
final columns = tableInfo.map((r) => r.read<String>('name')).toList(); containsAll(['account_type', 'jmap_url', 'username']),
);
expect(columns, contains('thread_id'));
expect(columns, contains('snoozed_until'));
expect(columns, contains('snoozed_from_mailbox_path'));
final accountsInfo =
await db.customSelect('PRAGMA table_info(accounts)').get();
final accountColumns =
accountsInfo.map((r) => r.read<String>('name')).toList();
expect(accountColumns, contains('account_type'));
expect(accountColumns, contains('username'));
expect(accountColumns, contains('manage_sieve_host')); expect(accountColumns, contains('manage_sieve_host'));
// v14: threading columns.
final emailColumns = await _tableColumns(db, 'emails');
expect(
emailColumns,
containsAll(['thread_id', 'message_id', 'in_reply_to', 'references']),
);
// v22: snooze columns.
expect(
emailColumns,
containsAll(['snoozed_until', 'snoozed_from_mailbox_path']),
);
// v23: list-unsubscribe header column.
expect(emailColumns, contains('list_unsubscribe_header'));
// v8: mailboxes role column.
final mailboxColumns = await _tableColumns(db, 'mailboxes');
expect(mailboxColumns, contains('role'));
// v9: email_bodies cached_at column.
final bodyColumns = await _tableColumns(db, 'email_bodies');
expect(bodyColumns, contains('cached_at'));
expect(bodyColumns, contains('headers_json'));
// v4: drafts table with v24 imap_server_id column.
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
// v5, v6, v7, v12, v17, v19, v21: new tables.
final allTables = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='table'")
.get();
final tableNames = allTables.map((r) => r.read<String>('name')).toList();
expect(
tableNames,
containsAll([
'sync_states', // v5
'pending_changes', // v6
'sync_logs', // v7
'sync_log_mailboxes', // v12
'threads', // v17
'sync_health', // v19
'undo_actions', // v21
]),
);
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test('fresh install (v22) works', () async { test(
final db = AppDatabase(NativeDatabase.memory()); 'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
// Just ensure we can create everything and query. () async {
final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL DEFAULT 993,
imap_ssl INTEGER NOT NULL DEFAULT 1 CHECK ("imap_ssl" IN (0, 1)),
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_ssl INTEGER NOT NULL DEFAULT 1 CHECK ("smtp_ssl" IN (0, 1)),
account_type TEXT NOT NULL DEFAULT 'imap',
jmap_url TEXT NULL,
username TEXT NULL,
manage_sieve_host TEXT NULL,
manage_sieve_port INTEGER NULL,
manage_sieve_ssl INTEGER NULL,
manage_sieve_available INTEGER NOT NULL DEFAULT 0 CHECK ("manage_sieve_available" IN (0, 1)),
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
);
''');
rawDb.execute('''
CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL,
reply_to_email_id TEXT NULL,
to_text TEXT NOT NULL DEFAULT '',
cc_text TEXT NOT NULL DEFAULT '',
subject_text TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL,
uid INTEGER NOT NULL,
subject TEXT NULL,
sent_at INTEGER NULL,
received_at INTEGER NOT NULL,
from_json TEXT NOT NULL DEFAULT '[]',
to_addresses TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
is_seen INTEGER NOT NULL DEFAULT 0 CHECK ("is_seen" IN (0, 1)),
is_flagged INTEGER NOT NULL DEFAULT 0 CHECK ("is_flagged" IN (0, 1)),
has_attachment INTEGER NOT NULL DEFAULT 0 CHECK ("has_attachment" IN (0, 1)),
thread_id TEXT NULL,
message_id TEXT NULL,
in_reply_to TEXT NULL,
"references" TEXT NULL,
snoozed_until INTEGER NULL,
snoozed_from_mailbox_path TEXT NULL
);
''');
rawDb.execute('PRAGMA user_version = 22;');
rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration.
await db.select(db.accounts).get(); await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 24', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
final allTables = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='table'")
.get();
final tableNames = allTables.map((r) => r.read<String>('name')).toSet();
expect(
tableNames,
containsAll([
'accounts',
'mailboxes',
'emails',
'email_bodies',
'drafts',
'sync_states',
'pending_changes',
'sync_logs',
'sync_log_mailboxes',
'threads',
'sync_health',
'undo_actions',
]),
);
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
await db.close(); await db.close();
}); });
}); });
+348
View File
@@ -0,0 +1,348 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
// ── helpers ───────────────────────────────────────────────────────────────────
Account _account({String id = 'a1'}) => Account(
id: id,
displayName: 'Test',
email: 'test@example.com',
imapHost: 'localhost',
);
class _FakeAccounts implements AccountRepository {
final List<Account> accounts;
_FakeAccounts([Account? account]) : accounts = [account ?? _account()];
@override
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
@override
Future<Account?> getAccount(String id) async =>
accounts.cast<Account?>().firstWhere(
(a) => a?.id == id,
orElse: () => null,
);
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
@override
Future<String> getPassword(String id) async => 'secret';
}
class _FakeMailboxes implements MailboxRepository {
final List<Mailbox> mailboxes;
_FakeMailboxes([this.mailboxes = const []]);
@override
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
Stream.value(mailboxes);
@override
Future<int> syncMailboxes(String accountId) async => 0;
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
@override
Future<void> clearForResync(String accountId) async {}
}
class _CountingEmails implements EmailRepository {
int syncCount = 0;
int wakeUpCount = 0;
final Exception? syncError;
_CountingEmails({this.syncError});
@override
Future<SyncEmailsResult> syncEmails(String accountId, String mailbox) async {
syncCount++;
if (syncError != null) throw syncError!;
return SyncEmailsResult.zero;
}
@override
Future<int> wakeUpEmails(String accountId) async {
wakeUpCount++;
return 0;
}
@override
Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override
Stream<List<Email>> observeEmails(String a, String m) => Stream.value([]);
@override
Stream<List<EmailThread>> observeThreads(String a, String m) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]);
@override
Future<Email?> getEmail(String id) async => null;
@override
Future<EmailBody> getEmailBody(String id) async =>
const EmailBody(emailId: '', attachments: []);
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@override
Future<String?> deleteEmail(String id) async => null;
@override
Future<void> sendEmail(String accountId, EmailDraft draft) async {}
@override
Future<String> downloadAttachment(String id, EmailAttachment att) async => '';
@override
Future<List<Email>> searchEmails(String a, String m, String q) async => [];
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
@override
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
Stream.value([]);
@override
Future<void> discardMutation(int id) async {}
@override
Future<void> retryMutation(int id) async {}
@override
Future<bool> cancelPendingChange(String id, String type) async => false;
@override
Future<void> snoozeEmail(String id, DateTime until) async {}
@override
Future<void> restoreEmails(List<Email> emails) async {}
@override
Stream<String> get onChangesQueued => const Stream.empty();
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();
@override
Future<ReliabilityResult> verifySyncReliability(
String accountId,
String mailboxPath,
) async =>
ReliabilityResult.healthy;
@override
Future<void> clearForResync(String accountId) async {}
}
class _FakeSyncLog implements SyncLogRepository {
final logs = <bool>[];
@override
Future<void> log({
required String accountId,
required bool success,
String? errorMessage,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
required int mailboxesSynced,
required int pendingFlushed,
required int bytesTransferred,
required DateTime startedAt,
required DateTime finishedAt,
List<MailboxSyncStats> mailboxStats = const [],
String? protocolLog,
}) async {
logs.add(success);
}
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
// ── tests ─────────────────────────────────────────────────────────────────────
void main() {
group('AccountSyncManager backoff', () {
test('backoff is capped at 900 s after repeated failures', () {
fakeAsync((async) {
final emails = _CountingEmails(
syncError: Exception('connection refused'),
);
final syncLog = _FakeSyncLog();
final manager = AccountSyncManager(
_FakeAccounts(),
_FakeMailboxes([
const Mailbox(
id: 'INBOX',
accountId: 'a1',
path: 'INBOX',
name: 'Inbox',
unreadCount: 0,
totalCount: 0,
),
]),
emails,
syncLog: syncLog,
imapConnect: (_, __, ___) async =>
throw Exception('connection refused'),
);
manager.start();
// Advance 3 hours — long enough to observe many retries.
// With max backoff 900 s, we expect at least floor(3*3600/900) = 12
// attempts, and at most 3*3600/5 = 2160 (if backoff never grew).
async.elapse(const Duration(hours: 3));
final failCount = syncLog.logs.where((ok) => !ok).length;
expect(
failCount,
greaterThan(10),
reason: 'should have retried many times within 3 h',
);
expect(
failCount,
lessThan(2200),
reason: 'backoff must have kicked in — not every 5 s for 3 h',
);
manager.dispose();
async.elapse(const Duration(seconds: 1));
});
});
test('backoff resets to 5 s after a successful sync', () {
fakeAsync((async) {
int callCount = 0;
final syncLog = _FakeSyncLog();
var failsLeft = 5;
final customEmails = _OverrideEmails(
onSync: (_) async {
callCount++;
if (failsLeft > 0) {
failsLeft--;
throw Exception('transient error');
}
return SyncEmailsResult.zero;
},
);
final manager = AccountSyncManager(
_FakeAccounts(),
_FakeMailboxes([
const Mailbox(
id: 'INBOX',
accountId: 'a1',
path: 'INBOX',
name: 'Inbox',
unreadCount: 0,
totalCount: 0,
),
]),
customEmails,
syncLog: syncLog,
imapConnect: (_, __, ___) async =>
throw Exception('skip idle — force immediate loop'),
);
manager.start();
// Allow errors + backoff to build up, then a success, then more loops.
async.elapse(const Duration(seconds: 3600));
// After success, backoff should reset; failures before success should
// be exactly 5, and subsequent loops should fire frequently.
final successCount = syncLog.logs.where((ok) => ok).length;
expect(
successCount,
greaterThan(0),
reason: 'should have at least one success',
);
expect(
callCount,
greaterThan(5),
reason: 'should retry after failures and continue after success',
);
manager.dispose();
async.elapse(const Duration(seconds: 1));
});
});
test('concurrent sync errors from multiple accounts stay bounded', () {
fakeAsync((async) {
final accounts = _FakeAccounts()
..accounts.add(_account(id: 'a2'))
..accounts.add(_account(id: 'a3'));
final syncLog = _FakeSyncLog();
final manager = AccountSyncManager(
accounts,
_FakeMailboxes([
const Mailbox(
id: 'INBOX',
accountId: 'a1',
path: 'INBOX',
name: 'Inbox',
unreadCount: 0,
totalCount: 0,
),
const Mailbox(
id: 'INBOX',
accountId: 'a2',
path: 'INBOX',
name: 'Inbox',
unreadCount: 0,
totalCount: 0,
),
const Mailbox(
id: 'INBOX',
accountId: 'a3',
path: 'INBOX',
name: 'Inbox',
unreadCount: 0,
totalCount: 0,
),
]),
_CountingEmails(syncError: Exception('network error')),
syncLog: syncLog,
imapConnect: (_, __, ___) async =>
throw Exception('connection refused'),
);
manager.start();
async.elapse(const Duration(hours: 2));
// All 3 accounts retry, each bounded by the 900 s cap.
final failCount = syncLog.logs.where((ok) => !ok).length;
expect(failCount, greaterThan(5));
expect(
failCount,
lessThan(5000),
reason: 'backoff must be in effect across all accounts',
);
manager.dispose();
async.elapse(const Duration(seconds: 1));
});
});
});
}
// ── _OverrideEmails ───────────────────────────────────────────────────────────
class _OverrideEmails extends _CountingEmails {
_OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync})
: _onSync = onSync;
final Future<SyncEmailsResult> Function(String) _onSync;
@override
Future<SyncEmailsResult> syncEmails(String accountId, String mailbox) =>
_onSync(mailbox);
}
+10
View File
@@ -452,6 +452,16 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
), ),
)), )),
) as _i4.Future<_i2.ReliabilityResult>); ) as _i4.Future<_i2.ReliabilityResult>);
@override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
} }
/// A class which mocks [UndoRepository]. /// A class which mocks [UndoRepository].
+16
View File
@@ -30,6 +30,7 @@ import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fake repositories // Fake repositories
@@ -114,6 +115,9 @@ class FakeDraftRepository implements DraftRepository {
@override @override
Future<void> deleteDraft(int id) async => _drafts.remove(id); Future<void> deleteDraft(int id) async => _drafts.remove(id);
@override
Future<void> syncDrafts(String accountId, String password) async {}
} }
class FakeMailboxRepository implements MailboxRepository { class FakeMailboxRepository implements MailboxRepository {
@@ -378,6 +382,18 @@ Widget buildApp({
), ),
], ],
), ),
GoRoute(
path: ':mailboxPath/threads/:threadId',
builder: (ctx, state) => ThreadDetailScreen(
accountId: state.pathParameters['accountId']!,
mailboxPath: Uri.decodeComponent(
state.pathParameters['mailboxPath']!,
),
threadId: Uri.decodeComponent(
state.pathParameters['threadId']!,
),
),
),
], ],
), ),
], ],
+182
View File
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('SearchScreen', () {
testWidgets('shows placeholder hint text when empty', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
testWidgets('typing fewer than 3 characters does not trigger search', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'hi');
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Type 3+ characters to search'), findsOneWidget);
expect(find.text('No results'), findsNothing);
});
testWidgets('shows "No results" when search returns nothing', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'xyz');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('No results'), findsOneWidget);
});
testWidgets('shows email results under "Messages" section', (
tester,
) async {
final email = testEmail(subject: 'Invoice Q3');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'inv');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Messages'), findsOneWidget);
expect(find.text('Invoice Q3'), findsOneWidget);
});
testWidgets('shows folder results under "Folders" section', (
tester,
) async {
const archiveMailbox = Mailbox(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
unreadCount: 0,
totalCount: 5,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'arc');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Folders'), findsOneWidget);
expect(find.text('Archive'), findsOneWidget);
});
testWidgets('tapping clear button resets results to placeholder', (
tester,
) async {
final email = testEmail(subject: 'Found email');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'found');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Found email'), findsOneWidget);
await tester.tap(find.byIcon(Icons.clear));
await tester.pumpAndSettle();
expect(find.text('Found email'), findsNothing);
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
Email _threadEmail({
String id = 'acc-1:10',
bool isFlagged = false,
bool isSeen = true,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 10,
threadId: 'thread-1',
subject: 'Project update',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6, 1, 9),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
void main() {
group('ThreadDetailScreen', () {
testWidgets('shows "Thread not found or empty" when thread is empty', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Thread not found or empty'), findsOneWidget);
});
testWidgets('shows sender name for email in thread', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Bob'), findsOneWidget);
});
testWidgets('last email in thread is expanded by default', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Hello body text',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
// Reply and delete buttons are visible for the expanded card.
expect(find.byIcon(Icons.reply), findsOneWidget);
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
});
testWidgets('tapping an expanded card collapses it', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Hello body text',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
// Tap the expand_less icon to collapse.
await tester.tap(find.byIcon(Icons.expand_less));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.reply), findsNothing);
expect(find.byIcon(Icons.expand_more), findsOneWidget);
});
testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.star), findsOneWidget);
});
testWidgets('expanded card shows plain text body', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Body content here',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Body content here'), findsOneWidget);
});
});
}