Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe154accea | ||
|
|
7421855922 | ||
|
|
855f9a3a6d | ||
|
|
a0c35c647a | ||
|
|
fc592c475f |
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -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) {
|
||||
@@ -117,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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'};
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user