Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe154accea | ||
|
|
7421855922 | ||
|
|
855f9a3a6d | ||
|
|
a0c35c647a | ||
|
|
fc592c475f |
@@ -7,6 +7,7 @@ class SavedDraft {
|
|||||||
final String subjectText;
|
final String subjectText;
|
||||||
final String bodyText;
|
final String bodyText;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
final String? imapServerId;
|
||||||
|
|
||||||
const SavedDraft({
|
const SavedDraft({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -17,5 +18,6 @@ class SavedDraft {
|
|||||||
required this.subjectText,
|
required this.subjectText,
|
||||||
required this.bodyText,
|
required this.bodyText,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
this.imapServerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Email {
|
|||||||
final String? references;
|
final String? references;
|
||||||
final DateTime? snoozedUntil;
|
final DateTime? snoozedUntil;
|
||||||
final String? snoozedFromMailboxPath;
|
final String? snoozedFromMailboxPath;
|
||||||
|
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
|
||||||
|
final String? listUnsubscribeHeader;
|
||||||
|
|
||||||
const Email({
|
const Email({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -43,6 +45,7 @@ class Email {
|
|||||||
this.references,
|
this.references,
|
||||||
this.snoozedUntil,
|
this.snoozedUntil,
|
||||||
this.snoozedFromMailboxPath,
|
this.snoozedFromMailboxPath,
|
||||||
|
this.listUnsubscribeHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Email.fromJson(Map<String, dynamic> json) {
|
factory Email.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -77,6 +80,7 @@ class Email {
|
|||||||
? DateTime.parse(json['snoozedUntil'] as String)
|
? DateTime.parse(json['snoozedUntil'] as String)
|
||||||
: null,
|
: null,
|
||||||
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
||||||
|
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ class Email {
|
|||||||
'references': references,
|
'references': references,
|
||||||
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
||||||
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
||||||
|
'listUnsubscribeHeader': listUnsubscribeHeader,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +131,7 @@ class Email {
|
|||||||
String? references,
|
String? references,
|
||||||
DateTime? snoozedUntil,
|
DateTime? snoozedUntil,
|
||||||
String? snoozedFromMailboxPath,
|
String? snoozedFromMailboxPath,
|
||||||
|
String? listUnsubscribeHeader,
|
||||||
}) {
|
}) {
|
||||||
return Email(
|
return Email(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -149,6 +155,8 @@ class Email {
|
|||||||
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
||||||
snoozedFromMailboxPath:
|
snoozedFromMailboxPath:
|
||||||
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
||||||
|
listUnsubscribeHeader:
|
||||||
|
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,10 @@ abstract class DraftRepository {
|
|||||||
|
|
||||||
/// Permanently removes the draft with [id].
|
/// Permanently removes the draft with [id].
|
||||||
Future<void> deleteDraft(int id);
|
Future<void> deleteDraft(int id);
|
||||||
|
|
||||||
|
/// Syncs local drafts with the server IMAP Drafts folder for [accountId].
|
||||||
|
/// Uploads local drafts that have no [SavedDraft.imapServerId]; imports
|
||||||
|
/// server drafts that are not already tracked locally.
|
||||||
|
/// No-op when the implementation has no IMAP connection configured.
|
||||||
|
Future<void> syncDrafts(String accountId, String password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:enough_mail/enough_mail.dart' as imap;
|
|||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
@@ -22,14 +23,17 @@ class AccountSyncManager {
|
|||||||
this._emails, {
|
this._emails, {
|
||||||
ImapConnectFn imapConnect = connectImap,
|
ImapConnectFn imapConnect = connectImap,
|
||||||
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
|
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
|
||||||
|
DraftRepository? drafts,
|
||||||
}) : _imapConnect = imapConnect,
|
}) : _imapConnect = imapConnect,
|
||||||
_syncLog = syncLog;
|
_syncLog = syncLog,
|
||||||
|
_drafts = drafts;
|
||||||
|
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
final MailboxRepository _mailboxes;
|
final MailboxRepository _mailboxes;
|
||||||
final EmailRepository _emails;
|
final EmailRepository _emails;
|
||||||
final ImapConnectFn _imapConnect;
|
final ImapConnectFn _imapConnect;
|
||||||
final SyncLogRepository _syncLog;
|
final SyncLogRepository _syncLog;
|
||||||
|
final DraftRepository? _drafts;
|
||||||
|
|
||||||
final Map<String, _SyncLoop> _active = {};
|
final Map<String, _SyncLoop> _active = {};
|
||||||
StreamSubscription<List<Account>>? _accountsSub;
|
StreamSubscription<List<Account>>? _accountsSub;
|
||||||
@@ -53,6 +57,7 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_imapConnect,
|
_imapConnect,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
|
_drafts,
|
||||||
),
|
),
|
||||||
AccountType.jmap => _JmapAccountSync(
|
AccountType.jmap => _JmapAccountSync(
|
||||||
account,
|
account,
|
||||||
@@ -113,6 +118,7 @@ class AccountSyncManager {
|
|||||||
_emails,
|
_emails,
|
||||||
_imapConnect,
|
_imapConnect,
|
||||||
_syncLog,
|
_syncLog,
|
||||||
|
_drafts,
|
||||||
),
|
),
|
||||||
AccountType.jmap => _JmapAccountSync(
|
AccountType.jmap => _JmapAccountSync(
|
||||||
account,
|
account,
|
||||||
@@ -145,6 +151,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
this._emails,
|
this._emails,
|
||||||
this._imapConnect,
|
this._imapConnect,
|
||||||
this._syncLog,
|
this._syncLog,
|
||||||
|
this._drafts,
|
||||||
);
|
);
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
@@ -153,6 +160,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
final EmailRepository _emails;
|
final EmailRepository _emails;
|
||||||
final ImapConnectFn _imapConnect;
|
final ImapConnectFn _imapConnect;
|
||||||
final SyncLogRepository _syncLog;
|
final SyncLogRepository _syncLog;
|
||||||
|
final DraftRepository? _drafts;
|
||||||
|
|
||||||
imap.ImapClient? _idleClient;
|
imap.ImapClient? _idleClient;
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
@@ -279,6 +287,8 @@ class _AccountSync implements _SyncLoop {
|
|||||||
Future<_SyncStats> _sync() async {
|
Future<_SyncStats> _sync() async {
|
||||||
final password = await _accounts.getPassword(account.id);
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
await _drafts?.syncDrafts(account.id, password);
|
||||||
|
|
||||||
// Check for expired snoozes and move them back to Inbox before syncing.
|
// Check for expired snoozes and move them back to Inbox before syncing.
|
||||||
await _emails.wakeUpEmails(account.id);
|
await _emails.wakeUpEmails(account.id);
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,21 @@ bool isLocalhost(String host) {
|
|||||||
final h = host.trim().toLowerCase();
|
final h = host.trim().toLowerCase();
|
||||||
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
|
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? validateHostname(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return 'Required';
|
||||||
|
return _checkHostChars(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? validateOptionalHostname(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return null;
|
||||||
|
return _checkHostChars(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _checkHostChars(String h) {
|
||||||
|
if (h.contains(RegExp(r'[@/\\]')) ||
|
||||||
|
h.codeUnits.any((c) => c < 32 || c == 127)) {
|
||||||
|
return 'Invalid hostname';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class Emails extends Table {
|
|||||||
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
||||||
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
||||||
|
|
||||||
|
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
|
||||||
|
TextColumn get listUnsubscribeHeader => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -227,6 +230,8 @@ class Drafts extends Table {
|
|||||||
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
||||||
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
||||||
DateTimeColumn get updatedAt => dateTime()();
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
// Added in schema v24: IMAP UID string ("mailbox:uid") on the server.
|
||||||
|
TextColumn get imapServerId => text().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DataClassName('UndoActionRow')
|
@DataClassName('UndoActionRow')
|
||||||
@@ -264,7 +269,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 22;
|
int get schemaVersion => 24;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -420,6 +425,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 23) {
|
||||||
|
await m.addColumn(emails, emails.listUnsubscribeHeader);
|
||||||
|
}
|
||||||
|
if (from >= 4 && from < 24) {
|
||||||
|
await m.addColumn(drafts, drafts.imapServerId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/draft.dart';
|
import 'package:sharedinbox/core/models/draft.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(this._db);
|
DraftRepositoryImpl(
|
||||||
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn? imapConnect,
|
||||||
|
}) : _imapConnect = imapConnect;
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accounts;
|
||||||
|
final ImapConnectFn? _imapConnect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SavedDraft> saveDraft({
|
Future<SavedDraft> saveDraft({
|
||||||
@@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
|
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncDrafts(String accountId, String password) async {
|
||||||
|
final connect = _imapConnect;
|
||||||
|
if (connect == null) return;
|
||||||
|
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null || account.type != AccountType.imap) return;
|
||||||
|
|
||||||
|
final username =
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
imap.ImapClient? client;
|
||||||
|
try {
|
||||||
|
client = await connect(account, username, password);
|
||||||
|
await _syncWithServer(client, accountId);
|
||||||
|
} finally {
|
||||||
|
await client?.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncWithServer(
|
||||||
|
imap.ImapClient client,
|
||||||
|
String accountId,
|
||||||
|
) async {
|
||||||
|
// Create/select the Drafts folder.
|
||||||
|
try {
|
||||||
|
await client.createMailbox('Drafts');
|
||||||
|
} catch (_) {
|
||||||
|
// Already exists.
|
||||||
|
}
|
||||||
|
final selectResult = await client.selectMailboxByPath('Drafts');
|
||||||
|
final messageCount = selectResult.messagesExists;
|
||||||
|
|
||||||
|
// Upload local drafts that have no server counterpart.
|
||||||
|
final localDrafts = await (_db.select(_db.drafts)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (final row in localDrafts) {
|
||||||
|
final builder = imap.MessageBuilder()
|
||||||
|
..to = _parseAddresses(row.toText)
|
||||||
|
..cc = _parseAddresses(row.ccText)
|
||||||
|
..subject = row.subjectText
|
||||||
|
..text = row.bodyText;
|
||||||
|
final mime = builder.buildMimeMessage();
|
||||||
|
final appendResult = await client.appendMessage(
|
||||||
|
mime,
|
||||||
|
targetMailboxPath: 'Drafts',
|
||||||
|
flags: [r'\Draft'],
|
||||||
|
);
|
||||||
|
final uidList =
|
||||||
|
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||||
|
final uid = (uidList != null && uidList.isNotEmpty)
|
||||||
|
? uidList.first.toString()
|
||||||
|
: null;
|
||||||
|
if (uid != null) {
|
||||||
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||||
|
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download server drafts not tracked locally.
|
||||||
|
if (messageCount > 0) {
|
||||||
|
final knownServerIds = await (_db.select(_db.drafts)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet();
|
||||||
|
|
||||||
|
final seq = imap.MessageSequence.fromAll();
|
||||||
|
final fetch = await client.uidFetchMessages(seq, '(UID FLAGS ENVELOPE)');
|
||||||
|
for (final msg in fetch.messages) {
|
||||||
|
final uid = msg.uid?.toString();
|
||||||
|
if (uid == null || knownIds.contains(uid)) continue;
|
||||||
|
if (msg.flags?.contains(r'\Deleted') ?? false) continue;
|
||||||
|
final env = msg.envelope;
|
||||||
|
final now = DateTime.now();
|
||||||
|
await _db.into(_db.drafts).insert(
|
||||||
|
DraftsCompanion.insert(
|
||||||
|
accountId: Value(accountId),
|
||||||
|
toText: Value(_addressListToText(env?.to)),
|
||||||
|
ccText: Value(_addressListToText(env?.cc)),
|
||||||
|
subjectText: Value(env?.subject ?? ''),
|
||||||
|
bodyText: const Value(''),
|
||||||
|
updatedAt: now,
|
||||||
|
imapServerId: Value(uid),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<imap.MailAddress> _parseAddresses(String text) {
|
||||||
|
if (text.trim().isEmpty) return [];
|
||||||
|
return text.split(',').map((s) => imap.MailAddress('', s.trim())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _addressListToText(List<imap.MailAddress>? addresses) {
|
||||||
|
if (addresses == null || addresses.isEmpty) return '';
|
||||||
|
return addresses.map((a) => a.email).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
SavedDraft _toModel(Draft row) => SavedDraft(
|
SavedDraft _toModel(Draft row) => SavedDraft(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
accountId: row.accountId,
|
accountId: row.accountId,
|
||||||
@@ -104,5 +218,6 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
subjectText: row.subjectText,
|
subjectText: row.subjectText,
|
||||||
bodyText: row.bodyText,
|
bodyText: row.bodyText,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
imapServerId: row.imapServerId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
imap.MessageSequence sequence,
|
imap.MessageSequence sequence,
|
||||||
) async {
|
) async {
|
||||||
const fetchItems =
|
const fetchItems =
|
||||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
|
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES LIST-UNSUBSCRIBE)])';
|
||||||
final fetch = sequence.isUidSequence
|
final fetch = sequence.isUidSequence
|
||||||
? await client.uidFetchMessages(sequence, fetchItems)
|
? await client.uidFetchMessages(sequence, fetchItems)
|
||||||
: await client.fetchMessages(sequence, fetchItems);
|
: await client.fetchMessages(sequence, fetchItems);
|
||||||
@@ -569,6 +569,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final msgId = envelope.messageId?.trim();
|
final msgId = envelope.messageId?.trim();
|
||||||
final inReplyTo = envelope.inReplyTo?.trim();
|
final inReplyTo = envelope.inReplyTo?.trim();
|
||||||
final refs = msg.getHeaderValue('References')?.trim();
|
final refs = msg.getHeaderValue('References')?.trim();
|
||||||
|
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
|
||||||
final threadId = _computeThreadId(
|
final threadId = _computeThreadId(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
@@ -612,6 +613,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
inReplyTo: Value(inReplyTo),
|
inReplyTo: Value(inReplyTo),
|
||||||
references: Value(refs),
|
references: Value(refs),
|
||||||
snoozedUntil: Value(snoozedUntil),
|
snoozedUntil: Value(snoozedUntil),
|
||||||
|
listUnsubscribeHeader: Value(listUnsubscribe),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -950,6 +952,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
'htmlBody',
|
'htmlBody',
|
||||||
'bodyValues',
|
'bodyValues',
|
||||||
'attachments',
|
'attachments',
|
||||||
|
'header:List-Unsubscribe:asText',
|
||||||
];
|
];
|
||||||
|
|
||||||
static const _emailGetBodyOptions = {
|
static const _emailGetBodyOptions = {
|
||||||
@@ -1151,6 +1154,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final jmapReferences = _joinJmapStringList(
|
final jmapReferences = _joinJmapStringList(
|
||||||
m['references'] as List<dynamic>?,
|
m['references'] as List<dynamic>?,
|
||||||
);
|
);
|
||||||
|
final jmapListUnsubscribe =
|
||||||
|
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
|
||||||
|
|
||||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
@@ -1173,6 +1178,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
inReplyTo: Value(jmapInReplyTo),
|
inReplyTo: Value(jmapInReplyTo),
|
||||||
references: Value(jmapReferences),
|
references: Value(jmapReferences),
|
||||||
snoozedUntil: Value(snoozedUntil),
|
snoozedUntil: Value(snoozedUntil),
|
||||||
|
listUnsubscribeHeader: Value(jmapListUnsubscribe),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2663,6 +2669,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
references: row.references,
|
references: row.references,
|
||||||
snoozedUntil: row.snoozedUntil,
|
snoozedUntil: row.snoozedUntil,
|
||||||
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
||||||
|
listUnsubscribeHeader: row.listUnsubscribeHeader,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -65,7 +65,11 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
||||||
return DraftRepositoryImpl(ref.watch(dbProvider));
|
return DraftRepositoryImpl(
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(accountRepositoryProvider),
|
||||||
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
||||||
@@ -117,6 +121,7 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
|||||||
ref.watch(emailRepositoryProvider),
|
ref.watch(emailRepositoryProvider),
|
||||||
syncLog: ref.watch(syncLogRepositoryProvider),
|
syncLog: ref.watch(syncLogRepositoryProvider),
|
||||||
imapConnect: ref.watch(imapConnectProvider),
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
drafts: ref.watch(draftRepositoryProvider),
|
||||||
);
|
);
|
||||||
ref.onDispose(manager.dispose);
|
ref.onDispose(manager.dispose);
|
||||||
return manager;
|
return manager;
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
_field(_passwordCtrl, 'Password', obscure: true),
|
_field(_passwordCtrl, 'Password', obscure: true),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
|
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_field(_imapHostCtrl, 'Host'),
|
_field(_imapHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
if (isLocalhost(_imapHostCtrl.text.trim()))
|
if (isLocalhost(_imapHostCtrl.text.trim()))
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -418,7 +418,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_field(_smtpHostCtrl, 'Host'),
|
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -475,6 +475,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
bool obscure = false,
|
bool obscure = false,
|
||||||
bool required = true,
|
bool required = true,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
@@ -486,9 +487,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: required
|
validator: validator ??
|
||||||
|
(required
|
||||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||||
: null,
|
: null),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,11 +324,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
'IMAP (SSL/TLS)',
|
'IMAP (SSL/TLS)',
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
_field(_imapHostCtrl, 'Host'),
|
_field(_imapHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_field(_smtpHostCtrl, 'Host'),
|
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
|
||||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -348,6 +348,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
_sieveHostCtrl,
|
_sieveHostCtrl,
|
||||||
'Host (leave blank to use IMAP host)',
|
'Host (leave blank to use IMAP host)',
|
||||||
required: false,
|
required: false,
|
||||||
|
validator: validateOptionalHostname,
|
||||||
),
|
),
|
||||||
_field(
|
_field(
|
||||||
_sievePortCtrl,
|
_sievePortCtrl,
|
||||||
@@ -408,6 +409,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
bool obscure = false,
|
bool obscure = false,
|
||||||
bool required = true,
|
bool required = true,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
@@ -420,9 +422,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: required
|
validator: validator ??
|
||||||
|
(required
|
||||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||||
: 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/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||||
|
|
||||||
@@ -267,6 +268,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
_dateFmt.format(email.sentAt!),
|
_dateFmt.format(email.sentAt!),
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
|
if (email.listUnsubscribeHeader != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: _UnsubscribeChip(header: email.listUnsubscribeHeader!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -462,6 +468,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a List-Unsubscribe header and returns the first usable URI.
|
||||||
|
/// Prefers mailto: so unsubscribing sends an email; falls back to https:.
|
||||||
|
Uri? _parseUnsubscribeUri(String header) {
|
||||||
|
final matches = RegExp(r'<([^>]+)>').allMatches(header);
|
||||||
|
Uri? fallback;
|
||||||
|
for (final m in matches) {
|
||||||
|
final raw = m.group(1)!.trim();
|
||||||
|
final uri = Uri.tryParse(raw);
|
||||||
|
if (uri == null) continue;
|
||||||
|
if (uri.scheme == 'mailto') return uri;
|
||||||
|
if ((uri.scheme == 'https' || uri.scheme == 'http') && fallback == null) {
|
||||||
|
fallback = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UnsubscribeChip extends StatelessWidget {
|
||||||
|
const _UnsubscribeChip({required this.header});
|
||||||
|
final String header;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final uri = _parseUnsubscribeUri(header);
|
||||||
|
if (uri == null) return const SizedBox.shrink();
|
||||||
|
return ActionChip(
|
||||||
|
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||||
|
label: const Text('Unsubscribe'),
|
||||||
|
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||||
@override
|
@override
|
||||||
Set<String> get supportedTags => {'img'};
|
Set<String> get supportedTags => {'img'};
|
||||||
|
|||||||
@@ -187,6 +187,16 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
|
|||||||
),
|
),
|
||||||
returnValue: _i4.Future<_i8.Mailbox?>.value(),
|
returnValue: _i4.Future<_i8.Mailbox?>.value(),
|
||||||
) as _i4.Future<_i8.Mailbox?>);
|
) as _i4.Future<_i8.Mailbox?>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#clearForResync,
|
||||||
|
[accountId],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A class which mocks [EmailRepository].
|
/// A class which mocks [EmailRepository].
|
||||||
@@ -582,4 +592,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i4.Future<_i2.ReliabilityResult>);
|
) as _i4.Future<_i2.ReliabilityResult>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#clearForResync,
|
||||||
|
[accountId],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
|
|
||||||
import 'db_test_helper.dart';
|
import 'db_test_helper.dart';
|
||||||
|
|
||||||
|
class _StubAccounts implements AccountRepository {
|
||||||
|
@override
|
||||||
|
Stream<List<Account>> observeAccounts() => const Stream.empty();
|
||||||
|
@override
|
||||||
|
Future<Account?> getAccount(String id) async => null;
|
||||||
|
@override
|
||||||
|
Future<void> addAccount(Account account, String password) async {}
|
||||||
|
@override
|
||||||
|
Future<void> updateAccount(Account account, {String? password}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> removeAccount(String id) async {}
|
||||||
|
@override
|
||||||
|
Future<String> getPassword(String accountId) async => '';
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(configureSqliteForTests);
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
@@ -11,7 +27,7 @@ void main() {
|
|||||||
test(
|
test(
|
||||||
'saveDraft creates a new row and returns it with a non-zero id',
|
'saveDraft creates a new row and returns it with a non-zero id',
|
||||||
() async {
|
() async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
final draft = await repo.saveDraft(
|
final draft = await repo.saveDraft(
|
||||||
toText: 'bob@example.com',
|
toText: 'bob@example.com',
|
||||||
ccText: '',
|
ccText: '',
|
||||||
@@ -25,7 +41,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test('saveDraft with id updates existing row', () async {
|
test('saveDraft with id updates existing row', () async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
final created = await repo.saveDraft(
|
final created = await repo.saveDraft(
|
||||||
toText: 'a@example.com',
|
toText: 'a@example.com',
|
||||||
ccText: '',
|
ccText: '',
|
||||||
@@ -47,19 +63,19 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('getDraft returns null for unknown id', () async {
|
test('getDraft returns null for unknown id', () async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
expect(await repo.getDraft(99999), isNull);
|
expect(await repo.getDraft(99999), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findDraft returns null when no draft exists', () async {
|
test('findDraft returns null when no draft exists', () async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
expect(await repo.findDraft(), isNull);
|
expect(await repo.findDraft(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'findDraft returns most recent draft for matching replyToEmailId',
|
'findDraft returns most recent draft for matching replyToEmailId',
|
||||||
() async {
|
() async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
await repo.saveDraft(
|
await repo.saveDraft(
|
||||||
replyToEmailId: 'email-1',
|
replyToEmailId: 'email-1',
|
||||||
toText: 'a@example.com',
|
toText: 'a@example.com',
|
||||||
@@ -83,7 +99,7 @@ void main() {
|
|||||||
test(
|
test(
|
||||||
'findDraft with null replyToEmailId finds new-message drafts',
|
'findDraft with null replyToEmailId finds new-message drafts',
|
||||||
() async {
|
() async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
// This draft is a reply and should NOT be returned.
|
// This draft is a reply and should NOT be returned.
|
||||||
await repo.saveDraft(
|
await repo.saveDraft(
|
||||||
replyToEmailId: 'email-1',
|
replyToEmailId: 'email-1',
|
||||||
@@ -104,7 +120,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test('deleteDraft removes the row', () async {
|
test('deleteDraft removes the row', () async {
|
||||||
final repo = DraftRepositoryImpl(openTestDatabase());
|
final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts());
|
||||||
final draft = await repo.saveDraft(
|
final draft = await repo.saveDraft(
|
||||||
toText: 'a@example.com',
|
toText: 'a@example.com',
|
||||||
ccText: '',
|
ccText: '',
|
||||||
|
|||||||
@@ -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>);
|
) as _i4.Future<_i2.ReliabilityResult>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#clearForResync,
|
||||||
|
[accountId],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A class which mocks [UndoRepository].
|
/// A class which mocks [UndoRepository].
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ class FakeDraftRepository implements DraftRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteDraft(int id) async => _drafts.remove(id);
|
Future<void> deleteDraft(int id) async => _drafts.remove(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncDrafts(String accountId, String password) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeMailboxRepository implements MailboxRepository {
|
class FakeMailboxRepository implements MailboxRepository {
|
||||||
|
|||||||
Reference in New Issue
Block a user