Compare commits

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

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