Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 fe154accea feat(U2): sync local drafts with IMAP Drafts folder
- Add imapServerId column (schema v24) to Drafts table; guard migration
  with from>=4 since createTable already uses latest schema
- Extend SavedDraft model and DraftRepository interface with syncDrafts()
- Implement syncDrafts in DraftRepositoryImpl: create/select Drafts
  folder, upload local-only drafts via APPEND, import server drafts not
  tracked locally
- Wire AccountRepository and ImapConnectFn into DraftRepositoryImpl via
  draftRepositoryProvider
- Call syncDrafts from AccountSyncManager._AccountSync._sync() so each
  IMAP sync cycle also syncs the Drafts folder
- Add syncDrafts stub to FakeDraftRepository; update unit test
  constructor calls with _StubAccounts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:23:54 +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
22 changed files with 720 additions and 26 deletions
+2
View File
@@ -7,6 +7,7 @@ class SavedDraft {
final String subjectText;
final String bodyText;
final DateTime updatedAt;
final String? imapServerId;
const SavedDraft({
required this.id,
@@ -17,5 +18,6 @@ class SavedDraft {
required this.subjectText,
required this.bodyText,
required this.updatedAt,
this.imapServerId,
});
}
+8
View File
@@ -21,6 +21,8 @@ class Email {
final String? references;
final DateTime? snoozedUntil;
final String? snoozedFromMailboxPath;
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
final String? listUnsubscribeHeader;
const Email({
required this.id,
@@ -43,6 +45,7 @@ class Email {
this.references,
this.snoozedUntil,
this.snoozedFromMailboxPath,
this.listUnsubscribeHeader,
});
factory Email.fromJson(Map<String, dynamic> json) {
@@ -77,6 +80,7 @@ class Email {
? DateTime.parse(json['snoozedUntil'] as String)
: null,
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
);
}
@@ -102,6 +106,7 @@ class Email {
'references': references,
'snoozedUntil': snoozedUntil?.toIso8601String(),
'snoozedFromMailboxPath': snoozedFromMailboxPath,
'listUnsubscribeHeader': listUnsubscribeHeader,
};
}
@@ -126,6 +131,7 @@ class Email {
String? references,
DateTime? snoozedUntil,
String? snoozedFromMailboxPath,
String? listUnsubscribeHeader,
}) {
return Email(
id: id ?? this.id,
@@ -149,6 +155,8 @@ class Email {
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
snoozedFromMailboxPath:
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
listUnsubscribeHeader:
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
);
}
}
@@ -21,4 +21,10 @@ abstract class DraftRepository {
/// Permanently removes the draft with [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);
}
@@ -65,6 +65,10 @@ abstract class SyncLogRepository {
});
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId);
/// Emits the error message of the most recent sync attempt for [accountId],
/// or null when the last sync succeeded (or no syncs have run yet).
Stream<String?> observeLastError(String accountId);
}
class NoOpSyncLogRepository implements SyncLogRepository {
@@ -90,4 +94,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
+11 -1
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/email.dart' show SyncEmailsResult;
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/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
@@ -22,14 +23,17 @@ class AccountSyncManager {
this._emails, {
ImapConnectFn imapConnect = connectImap,
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
DraftRepository? drafts,
}) : _imapConnect = imapConnect,
_syncLog = syncLog;
_syncLog = syncLog,
_drafts = drafts;
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final Map<String, _SyncLoop> _active = {};
StreamSubscription<List<Account>>? _accountsSub;
@@ -53,6 +57,7 @@ class AccountSyncManager {
_emails,
_imapConnect,
_syncLog,
_drafts,
),
AccountType.jmap => _JmapAccountSync(
account,
@@ -113,6 +118,7 @@ class AccountSyncManager {
_emails,
_imapConnect,
_syncLog,
_drafts,
),
AccountType.jmap => _JmapAccountSync(
account,
@@ -145,6 +151,7 @@ class _AccountSync implements _SyncLoop {
this._emails,
this._imapConnect,
this._syncLog,
this._drafts,
);
final Account account;
@@ -153,6 +160,7 @@ class _AccountSync implements _SyncLoop {
final EmailRepository _emails;
final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
imap.ImapClient? _idleClient;
bool _running = false;
@@ -279,6 +287,8 @@ class _AccountSync implements _SyncLoop {
Future<_SyncStats> _sync() async {
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.
await _emails.wakeUpEmails(account.id);
+18
View File
@@ -2,3 +2,21 @@ bool isLocalhost(String host) {
final h = host.trim().toLowerCase();
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()();
TextColumn get snoozedFromMailboxPath => text().nullable()();
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
TextColumn get listUnsubscribeHeader => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -227,6 +230,8 @@ class Drafts extends Table {
TextColumn get subjectText => text().withDefault(const Constant(''))();
TextColumn get bodyText => text().withDefault(const Constant(''))();
DateTimeColumn get updatedAt => dateTime()();
// Added in schema v24: IMAP UID string ("mailbox:uid") on the server.
TextColumn get imapServerId => text().nullable()();
}
@DataClassName('UndoActionRow')
@@ -264,7 +269,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 22;
int get schemaVersion => 24;
@override
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: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/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db);
DraftRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn? _imapConnect;
@override
Future<SavedDraft> saveDraft({
@@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository {
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(
id: row.id,
accountId: row.accountId,
@@ -104,5 +218,6 @@ class DraftRepositoryImpl implements DraftRepository {
subjectText: row.subjectText,
bodyText: row.bodyText,
updatedAt: row.updatedAt,
imapServerId: row.imapServerId,
);
}
@@ -528,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository {
imap.MessageSequence sequence,
) async {
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
? await client.uidFetchMessages(sequence, fetchItems)
: await client.fetchMessages(sequence, fetchItems);
@@ -569,6 +569,7 @@ class EmailRepositoryImpl implements EmailRepository {
final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim();
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
final threadId = _computeThreadId(
emailId: emailId,
messageId: msgId,
@@ -612,6 +613,7 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(inReplyTo),
references: Value(refs),
snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(listUnsubscribe),
),
);
}
@@ -950,6 +952,7 @@ class EmailRepositoryImpl implements EmailRepository {
'htmlBody',
'bodyValues',
'attachments',
'header:List-Unsubscribe:asText',
];
static const _emailGetBodyOptions = {
@@ -1151,6 +1154,8 @@ class EmailRepositoryImpl implements EmailRepository {
final jmapReferences = _joinJmapStringList(
m['references'] as List<dynamic>?,
);
final jmapListUnsubscribe =
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert(
@@ -1173,6 +1178,7 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(jmapInReplyTo),
references: Value(jmapReferences),
snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(jmapListUnsubscribe),
),
);
@@ -2663,6 +2669,7 @@ class EmailRepositoryImpl implements EmailRepository {
references: row.references,
snoozedUntil: row.snoozedUntil,
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
listUnsubscribeHeader: row.listUnsubscribeHeader,
);
}
@@ -99,4 +99,14 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
return entries;
});
}
@override
Stream<String?> observeLastError(String accountId) {
return (_db.select(_db.syncLogs)
..where((t) => t.accountId.equals(accountId))
..orderBy([(t) => OrderingTerm.desc(t.startedAt)])
..limit(1))
.watchSingleOrNull()
.map((row) => (row?.result == 'error') ? row?.errorMessage : null);
}
}
+11 -1
View File
@@ -65,7 +65,11 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((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) {
@@ -85,6 +89,11 @@ final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
final syncLastErrorProvider =
StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
});
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
final runner = ReliabilityRunner(
ref.watch(dbProvider),
@@ -112,6 +121,7 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
ref.watch(emailRepositoryProvider),
syncLog: ref.watch(syncLogRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
drafts: ref.watch(draftRepositoryProvider),
);
ref.onDispose(manager.dispose);
return manager;
+6 -4
View File
@@ -408,7 +408,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
_field(_passwordCtrl, 'Password', obscure: true),
const Divider(height: 32),
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'),
_field(_imapHostCtrl, 'Host', validator: validateHostname),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_imapHostCtrl.text.trim()))
SwitchListTile(
@@ -418,7 +418,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
),
const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'),
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile(
@@ -475,6 +475,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
bool obscure = false,
bool required = true,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
@@ -486,9 +487,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
labelText: label,
border: const OutlineInputBorder(),
),
validator: required
validator: validator ??
(required
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null,
: null),
),
);
}
+7 -4
View File
@@ -324,11 +324,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
'IMAP (SSL/TLS)',
style: Theme.of(context).textTheme.titleSmall,
),
_field(_imapHostCtrl, 'Host'),
_field(_imapHostCtrl, 'Host', validator: validateHostname),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'),
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile(
@@ -348,6 +348,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_sieveHostCtrl,
'Host (leave blank to use IMAP host)',
required: false,
validator: validateOptionalHostname,
),
_field(
_sievePortCtrl,
@@ -408,6 +409,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
bool obscure = false,
bool required = true,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
@@ -420,9 +422,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
labelText: label,
border: const OutlineInputBorder(),
),
validator: required
validator: validator ??
(required
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: 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/di.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');
@@ -267,6 +268,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_dateFmt.format(email.sentAt!),
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 {
@override
Set<String> get supportedTags => {'img'};
+44 -1
View File
@@ -35,6 +35,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searchLoading = false;
bool get _searching => _searchController.text.isNotEmpty;
// Error banner — tracks the last error message that the user dismissed.
String? _dismissedError;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
@@ -131,9 +134,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: (_searchResults != null || _searchLoading)
body: Column(
children: [
_buildSyncErrorBanner(),
Expanded(
child: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
),
],
),
);
}
@@ -267,6 +277,39 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
return _buildEmailList(_searchResults!);
}
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text(
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon(
Icons.sync_problem,
color: Theme.of(context).colorScheme.error,
),
backgroundColor: Theme.of(context).colorScheme.errorContainer,
actions: [
TextButton(
onPressed: () {
ref.read(syncManagerProvider).syncNow(widget.accountId);
},
child: const Text('Retry'),
),
TextButton(
onPressed: () => setState(() => _dismissedError = error),
child: const Text('Dismiss'),
),
],
);
}
Widget _buildStreamBody(EmailRepository emailRepo) {
return RefreshIndicator(
onRefresh: () async {
@@ -220,4 +220,7 @@ class _FakeLogs implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
+3
View File
@@ -132,6 +132,9 @@ class FakeSyncLogRepository implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@@ -187,6 +187,16 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
),
returnValue: _i4.Future<_i8.Mailbox?>.value(),
) 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].
@@ -582,4 +592,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
),
)),
) 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:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/repositories/draft_repository_impl.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() {
setUpAll(configureSqliteForTests);
@@ -11,7 +27,7 @@ void main() {
test(
'saveDraft creates a new row and returns it with a non-zero id',
() async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
final draft = await repo.saveDraft(
toText: 'bob@example.com',
ccText: '',
@@ -25,7 +41,7 @@ void main() {
);
test('saveDraft with id updates existing row', () async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
final created = await repo.saveDraft(
toText: 'a@example.com',
ccText: '',
@@ -47,19 +63,19 @@ void main() {
});
test('getDraft returns null for unknown id', () async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
expect(await repo.getDraft(99999), isNull);
});
test('findDraft returns null when no draft exists', () async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
expect(await repo.findDraft(), isNull);
});
test(
'findDraft returns most recent draft for matching replyToEmailId',
() async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
await repo.saveDraft(
replyToEmailId: 'email-1',
toText: 'a@example.com',
@@ -83,7 +99,7 @@ void main() {
test(
'findDraft with null replyToEmailId finds new-message drafts',
() async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
// This draft is a reply and should NOT be returned.
await repo.saveDraft(
replyToEmailId: 'email-1',
@@ -104,7 +120,7 @@ void main() {
);
test('deleteDraft removes the row', () async {
final repo = DraftRepositoryImpl(openTestDatabase());
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
final draft = await repo.saveDraft(
toText: 'a@example.com',
ccText: '',
+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>);
@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].
+3
View File
@@ -114,6 +114,9 @@ class FakeDraftRepository implements DraftRepository {
@override
Future<void> deleteDraft(int id) async => _drafts.remove(id);
@override
Future<void> syncDrafts(String accountId, String password) async {}
}
class FakeMailboxRepository implements MailboxRepository {