Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe154accea | ||
|
|
7421855922 | ||
|
|
855f9a3a6d | ||
|
|
a0c35c647a |
@@ -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: '',
|
||||
|
||||
@@ -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