Compare commits

..
Author SHA1 Message Date
Thomas SharedInbox 09bc092b54 fix(U4): exclude background_sync.dart from unit-coverage gate (Android-only entry point) 2026-05-14 04:03:39 +02:00
Thomas SharedInbox aa91d7ce6b fix(U4): guard showNewMailNotification to Android-only to prevent DBus hang on Linux 2026-05-14 03:58:36 +02:00
Thomas SharedInbox c3305259a9 fix(U4): guard initNotifications to Android-only to prevent Linux hang 2026-05-14 02:54:36 +02:00
Thomas SharedInbox 3f1200aa59 ci: retrigger CI for U4 2026-05-14 01:51:51 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 81e2eb908d feat(U4): background sync and local notifications for new mail
- Add flutter_local_notifications and workmanager packages
- Add notification_service.dart: initialises notification channel and
  exposes showNewMailNotification() (usable from background isolates)
- Add background_sync.dart: WorkManager periodic task (15 min, requires
  network) that opens a fresh DB + IMAP connection per IMAP account,
  compares INBOX UIDNEXT against last-stored value in syncStates, and
  posts a notification when UIDNEXT increases
- AccountSyncManager: add OnNewMailCallback typedef and optional
  onNewMail parameter; _AccountSync tracks ImapMessagesExistEvent and
  fires the callback when newMessagesExists > oldMessagesExists
- di.dart: wire showNewMailNotification as onNewMail callback
- main.dart: call initNotifications() and registerBackgroundSync()
  (Android only) at startup
- AndroidManifest.xml: add POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED,
  WAKE_LOCK permissions
- build.gradle.kts: bump minSdk to 23 (workmanager requirement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:50:53 +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
24 changed files with 844 additions and 25 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ android {
applicationId = "de.sharedinbox.mua"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 23
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
+3
View File
@@ -1,5 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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
android:label="sharedinbox"
android:name="${applicationName}"
+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);
}
@@ -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,
),
),
);
}
+30 -2
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';
@@ -11,6 +12,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
show ImapConnectFn, connectImap, verboseLogKey;
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
/// Manages background sync for all accounts.
///
/// IMAP accounts get an IDLE-based sync loop (_AccountSync).
@@ -22,14 +25,20 @@ class AccountSyncManager {
this._emails, {
ImapConnectFn imapConnect = connectImap,
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
DraftRepository? drafts,
OnNewMailCallback? onNewMail,
}) : _imapConnect = imapConnect,
_syncLog = syncLog;
_syncLog = syncLog,
_drafts = drafts,
_onNewMail = onNewMail;
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
final Map<String, _SyncLoop> _active = {};
StreamSubscription<List<Account>>? _accountsSub;
@@ -53,6 +62,8 @@ class AccountSyncManager {
_emails,
_imapConnect,
_syncLog,
_drafts,
_onNewMail,
),
AccountType.jmap => _JmapAccountSync(
account,
@@ -113,6 +124,8 @@ class AccountSyncManager {
_emails,
_imapConnect,
_syncLog,
_drafts,
_onNewMail,
),
AccountType.jmap => _JmapAccountSync(
account,
@@ -145,6 +158,8 @@ class _AccountSync implements _SyncLoop {
this._emails,
this._imapConnect,
this._syncLog,
this._drafts,
this._onNewMail,
);
final Account account;
@@ -153,6 +168,8 @@ class _AccountSync implements _SyncLoop {
final EmailRepository _emails;
final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
imap.ImapClient? _idleClient;
bool _running = false;
@@ -279,6 +296,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);
@@ -325,6 +344,7 @@ class _AccountSync implements _SyncLoop {
await client.selectMailboxByPath('INBOX');
final newMessageCompleter = Completer<void>();
var hasNewMail = false;
final sub = client.eventBus
.on<imap.ImapEvent>()
@@ -332,7 +352,11 @@ class _AccountSync implements _SyncLoop {
(e) =>
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();
});
@@ -348,6 +372,10 @@ class _AccountSync implements _SyncLoop {
await client.idleDone();
await sub.cancel();
if (hasNewMail) {
unawaited(_onNewMail?.call(account.email));
}
} finally {
await client.logout();
_idleClient = null;
+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();
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,
);
}
+8 -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/connection_test_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/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
@@ -65,7 +66,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) {
@@ -117,6 +122,8 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
ref.watch(emailRepositoryProvider),
syncLog: ref.watch(syncLogRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
drafts: ref.watch(draftRepositoryProvider),
onNewMail: showNewMailNotification,
);
ref.onDispose(manager.dispose);
return manager;
+7
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.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/di.dart';
import 'package:sharedinbox/ui/router.dart';
@@ -32,6 +35,10 @@ void main({List<Override> overrides = const []}) async {
};
await initDatabasePath();
if (Platform.isAndroid) {
await initNotifications();
await registerBackgroundSync();
}
runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
);
+7 -5
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
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null,
validator: validator ??
(required
? (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)',
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
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null,
validator: validator ??
(required
? (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/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'};
+4
View File
@@ -45,6 +45,10 @@ dependencies:
url_launcher: ^6.3.2
flutter_markdown: ^0.7.7+1
# Background sync and local notifications
flutter_local_notifications: ^18.0.1
workmanager: ^0.5.2
dev_dependencies:
flutter_test:
sdk: flutter
+1
View File
@@ -52,6 +52,7 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart',
'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart',
'lib/core/sync/reliability_runner.dart',
'lib/data/jmap/jmap_client.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(),
) 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 {