feat: IMAP CONDSTORE fast-path, JMAP blob TTL, offline compose queue UI

- IMAP CONDSTORE (RFC 7162): skip sync when HIGHESTMODSEQ is unchanged;
  refresh only changed flags via CHANGEDSINCE on incremental sync
- JMAP blob expiry: re-fetch email bodies older than 7 days (schema v8→v9
  adds nullable cachedAt column to email_bodies)
- Offline compose queue: expose stuck pending_changes rows via
  observeFailedMutations / retryMutation / discardMutation; surface them
  in a FailedMutationBanner on the mailbox list screen
- Unit tests for all three features (236 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-20 06:32:33 +02:00
co-authored by Claude Sonnet 4.6
parent 650c7a70f5
commit d5a5c7fbe3
11 changed files with 585 additions and 37 deletions
+5 -1
View File
@@ -1,6 +1,10 @@
# Later
Push to guettli@thomas-guettler via ssh+git
LINTING.md
---
Sieve: JMAP, easy. Per IMAP...
---
+23
View File
@@ -74,6 +74,29 @@ class EmailAttachment {
});
}
/// A pending local mutation (flag, move, delete) that has failed at least once
/// and may be stuck in the outbound queue.
class FailedMutation {
final int id;
final String accountId;
/// "flag_seen" | "flag_flagged" | "move" | "delete"
final String changeType;
final String resourceId;
final String lastError;
final int attempts;
final DateTime createdAt;
const FailedMutation({
required this.id,
required this.accountId,
required this.changeType,
required this.resourceId,
required this.lastError,
required this.attempts,
required this.createdAt,
});
}
/// Outgoing email — used for compose / reply.
class EmailDraft {
final EmailAddress from;
@@ -31,6 +31,17 @@ abstract class EmailRepository {
/// No-op for IMAP accounts (mutations are applied synchronously).
Future<void> flushPendingChanges(String accountId, String password);
/// Emits the list of pending mutations that have failed at least once for
/// [accountId]. Updates live whenever the queue changes.
Stream<List<FailedMutation>> observeFailedMutations(String accountId);
/// Permanently removes the pending mutation with [id] from the queue.
Future<void> discardMutation(int id);
/// Resets the attempt counter for mutation [id] so the next sync cycle
/// retries it.
Future<void> retryMutation(int id);
/// Returns a stream that emits once for each JMAP push event (RFC 8887
/// `StateChange`) received from the server's EventSource URL.
///
+7 -1
View File
@@ -77,6 +77,9 @@ class EmailBodies extends Table {
// JSON-encoded List<{filename,contentType,size}>
TextColumn get attachmentsJson =>
text().withDefault(const Constant('[]'))();
// Added in schema v9: when the body was last fetched from the server.
// Null for rows cached before this column was added (treated as expired).
DateTimeColumn get cachedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {emailId};
@@ -152,7 +155,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 8;
int get schemaVersion => 9;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -179,6 +182,9 @@ class AppDatabase extends _$AppDatabase {
if (from < 8) {
await m.addColumn(mailboxes, mailboxes.role);
}
if (from < 9) {
await m.addColumn(emailBodies, emailBodies.cachedAt);
}
},
);
}
+119 -12
View File
@@ -74,12 +74,20 @@ class EmailRepositoryImpl implements EmailRepository {
// ── Body (on-demand) ───────────────────────────────────────────────────────
static const _bodyCacheTtl = Duration(days: 7);
@override
Future<model.EmailBody> getEmailBody(String emailId) async {
final cached = await (_db.select(_db.emailBodies)
..where((t) => t.emailId.equals(emailId)))
.getSingleOrNull();
if (cached != null) return _bodyRowToModel(cached);
if (cached != null) {
// Re-fetch if cachedAt is null (legacy row) or older than the TTL.
final age = cached.cachedAt == null
? _bodyCacheTtl + const Duration(seconds: 1)
: DateTime.now().difference(cached.cachedAt!);
if (age <= _bodyCacheTtl) return _bodyRowToModel(cached);
}
final emailRow = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
@@ -120,6 +128,7 @@ class EmailRepositoryImpl implements EmailRepository {
textBody: Value(textBody),
htmlBody: Value(htmlBody),
attachmentsJson: Value(attachmentsJson),
cachedAt: Value(DateTime.now()),
),
);
return model.EmailBody(
@@ -182,6 +191,7 @@ class EmailRepositoryImpl implements EmailRepository {
textBody: Value(textBody),
htmlBody: Value(htmlBody),
attachmentsJson: Value(attachmentsJson),
cachedAt: Value(DateTime.now()),
),
);
@@ -215,8 +225,12 @@ class EmailRepositoryImpl implements EmailRepository {
final client =
await _imapConnect(account, _effectiveUsername(account), password);
try {
final selectedMailbox = await client.selectMailboxByPath(mailboxPath);
// Enable CONDSTORE so the server returns HIGHESTMODSEQ in SELECT and
// honours CHANGEDSINCE modifiers on FETCH (RFC 7162).
final selectedMailbox = await client.selectMailboxByPath(
mailboxPath, enableCondStore: true);
final uidValidity = selectedMailbox.uidValidity ?? 0;
final serverModSeq = selectedMailbox.highestModSequence;
final resourceType = 'IMAP:$mailboxPath';
final checkpoint = await _loadImapCheckpoint(account.id, resourceType);
@@ -234,19 +248,38 @@ class EmailRepositoryImpl implements EmailRepository {
client, account, mailboxPath, imap.MessageSequence.fromAll());
final maxUid = await _maxLocalUid(account.id, mailboxPath);
await _saveImapCheckpoint(
account.id, resourceType, uidValidity, maxUid);
account.id, resourceType, uidValidity, maxUid,
highestModSeq: serverModSeq);
} else {
// Incremental sync.
final lastUid = checkpoint['lastUid'] as int;
final storedModSeq = checkpoint['highestModSeq'] as int?;
// CONDSTORE fast-path: nothing has changed on the server.
if (serverModSeq != null &&
storedModSeq != null &&
serverModSeq == storedModSeq) {
return;
}
// Fetch new messages.
final newUids =
(await client.uidSearchMessages(searchCriteria: 'UID ${lastUid + 1}:*'))
.matchingSequence
?.toList() ??
[];
(await client.uidSearchMessages(
searchCriteria: 'UID ${lastUid + 1}:*'))
.matchingSequence
?.toList() ??
[];
if (newUids.isNotEmpty) {
await _fetchAndUpsertImap(client, account, mailboxPath,
imap.MessageSequence.fromIds(newUids, isUid: true));
}
// CONDSTORE flag update: refresh flags only for messages that changed.
if (serverModSeq != null && storedModSeq != null) {
await _refreshFlagsImap(
client, account, mailboxPath, storedModSeq);
}
// Detect remote deletions.
final serverUids =
(await client.uidSearchMessages(searchCriteria: 'ALL'))
@@ -257,13 +290,40 @@ class EmailRepositoryImpl implements EmailRepository {
final maxUid =
serverUids.isEmpty ? lastUid : serverUids.reduce(math.max);
await _saveImapCheckpoint(
account.id, resourceType, uidValidity, maxUid);
account.id, resourceType, uidValidity, maxUid,
highestModSeq: serverModSeq);
}
} finally {
await client.logout();
}
}
/// Fetches FLAGS for all messages modified since [sinceModSeq] and updates
/// the local DB. Only messages whose modseq is > [sinceModSeq] are returned
/// by the server (RFC 7162 §3.2).
Future<void> _refreshFlagsImap(
imap.ImapClient client,
account_model.Account account,
String mailboxPath,
int sinceModSeq,
) async {
final result = await client.uidFetchMessages(
imap.MessageSequence.fromAll(),
'FLAGS',
changedSinceModSequence: sinceModSeq,
);
for (final msg in result.messages) {
final uid = msg.uid;
if (uid == null) continue;
final emailId = '${account.id}:$uid';
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId)))
.write(EmailsCompanion(
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false),
));
}
}
Future<void> _fetchAndUpsertImap(
imap.ImapClient client,
account_model.Account account,
@@ -315,10 +375,19 @@ class EmailRepositoryImpl implements EmailRepository {
return jsonDecode(raw) as Map<String, dynamic>;
}
Future<void> _saveImapCheckpoint(String accountId, String resourceType,
int uidValidity, int lastUid) async {
await _saveSyncState(accountId, resourceType,
jsonEncode({'uidValidity': uidValidity, 'lastUid': lastUid}));
Future<void> _saveImapCheckpoint(
String accountId,
String resourceType,
int uidValidity,
int lastUid, {
int? highestModSeq,
}) async {
final data = <String, dynamic>{
'uidValidity': uidValidity,
'lastUid': lastUid,
};
if (highestModSeq != null) data['highestModSeq'] = highestModSeq;
await _saveSyncState(accountId, resourceType, jsonEncode(data));
}
Future<void> _reconcileDeletedImap(
@@ -516,6 +585,7 @@ class EmailRepositoryImpl implements EmailRepository {
textBody: Value(textBody),
htmlBody: Value(htmlBody),
attachmentsJson: Value(attachmentsJson),
cachedAt: Value(DateTime.now()),
),
);
}
@@ -1422,4 +1492,41 @@ class EmailRepositoryImpl implements EmailRepository {
)
.toList();
}
// ── Failed mutations (offline compose queue) ─────────────────────────────
@override
Stream<List<model.FailedMutation>> observeFailedMutations(
String accountId) {
return (_db.select(_db.pendingChanges)
..where((t) =>
t.accountId.equals(accountId) & t.lastError.isNotNull())
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch()
.map((rows) => rows
.map((r) => model.FailedMutation(
id: r.id,
accountId: r.accountId,
changeType: r.changeType,
resourceId: r.resourceId,
lastError: r.lastError!,
attempts: r.attempts,
createdAt: r.createdAt,
))
.toList());
}
@override
Future<void> discardMutation(int id) async {
await (_db.delete(_db.pendingChanges)..where((t) => t.id.equals(id))).go();
}
@override
Future<void> retryMutation(int id) async {
await (_db.update(_db.pendingChanges)..where((t) => t.id.equals(id)))
.write(const PendingChangesCompanion(
attempts: Value(0),
lastError: Value(null),
));
}
}
+114 -23
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/models/email.dart';
import '../../core/repositories/email_repository.dart';
import '../../di.dart';
class MailboxListScreen extends ConsumerWidget {
@@ -10,33 +12,122 @@ class MailboxListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(mailboxRepositoryProvider);
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Mailboxes')),
body: StreamBuilder(
stream: repo.observeMailboxes(accountId),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final mailboxes = snap.data!;
return ListView.builder(
itemCount: mailboxes.length,
itemBuilder: (ctx, i) {
final mb = mailboxes[i];
return ListTile(
leading: const Icon(Icons.folder),
title: Text(mb.name),
trailing: mb.unreadCount > 0
? Badge(label: Text('${mb.unreadCount}'))
: null,
onTap: () => context.push(
'/accounts/$accountId/mailboxes/${Uri.encodeComponent(mb.path)}/emails',
),
body: Column(
children: [
// ── Failed-mutation banner ───────────────────────────────────────
StreamBuilder<List<FailedMutation>>(
stream: emailRepo.observeFailedMutations(accountId),
builder: (ctx, snap) {
final mutations = snap.data ?? [];
if (mutations.isEmpty) return const SizedBox.shrink();
return _FailedMutationBanner(
mutations: mutations,
emailRepo: emailRepo,
);
},
);
},
),
// ── Mailbox list ─────────────────────────────────────────────────
Expanded(
child: StreamBuilder(
stream: mailboxRepo.observeMailboxes(accountId),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final mailboxes = snap.data!;
return ListView.builder(
itemCount: mailboxes.length,
itemBuilder: (ctx, i) {
final mb = mailboxes[i];
return ListTile(
leading: const Icon(Icons.folder),
title: Text(mb.name),
trailing: mb.unreadCount > 0
? Badge(label: Text('${mb.unreadCount}'))
: null,
onTap: () => context.push(
'/accounts/$accountId/mailboxes/${Uri.encodeComponent(mb.path)}/emails',
),
);
},
);
},
),
),
],
),
);
}
}
class _FailedMutationBanner extends StatelessWidget {
const _FailedMutationBanner({
required this.mutations,
required this.emailRepo,
});
final List<FailedMutation> mutations;
final EmailRepository emailRepo;
String _label(FailedMutation m) {
final noun = switch (m.changeType) {
'flag_seen' || 'flag_flagged' => 'flag change',
'move' => 'move',
'delete' => 'deletion',
_ => 'change',
};
return '${mutations.length} pending $noun${mutations.length > 1 ? 's' : ''} failed';
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.warning_amber,
color: Theme.of(context).colorScheme.onErrorContainer,
size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_label(mutations.first),
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer),
),
),
TextButton(
onPressed: () async {
for (final m in mutations) {
await emailRepo.retryMutation(m.id);
}
},
child: Text(
'Retry',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer),
),
),
TextButton(
onPressed: () async {
for (final m in mutations) {
await emailRepo.discardMutation(m.id);
}
},
child: Text(
'Discard',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
),
),
);
}
@@ -99,6 +99,16 @@ class _FakeEmails implements EmailRepository {
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();
@override
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
Stream.value([]);
@override
Future<void> discardMutation(int id) async {}
@override
Future<void> retryMutation(int id) async {}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
+10
View File
@@ -104,6 +104,16 @@ class FakeEmailRepository implements EmailRepository {
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();
@override
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
Stream.value([]);
@override
Future<void> discardMutation(int id) async {}
@override
Future<void> retryMutation(int id) async {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
+259
View File
@@ -318,6 +318,7 @@ void main() {
emailId: 'acc-1:1',
textBody: const Value('Hello'),
htmlBody: const Value('<p>Hello</p>'),
cachedAt: Value(DateTime.now()),
),
);
@@ -1797,6 +1798,264 @@ void main() {
await sseController.close();
});
});
// ── CONDSTORE tests ──────────────────────────────────────────────────────────
group('CONDSTORE', () {
test('fast-path: skips search/fetch when modseq is unchanged', () async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
r.fakeImap.uidValidityResult = 1000;
r.fakeImap.highestModSequenceResult = 42;
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'acc-1',
resourceType: 'IMAP:INBOX',
state: jsonEncode(
{'uidValidity': 1000, 'lastUid': 5, 'highestModSeq': 42}),
syncedAt: DateTime.now(),
),
);
await r.emails.syncEmails('acc-1', 'INBOX');
// No search or fetch calls because modseq is unchanged.
expect(r.fakeImap.uidFetchMessagesCalls, 0);
expect(r.fakeImap.logoutCalled, isTrue);
});
test('flag refresh: calls uidFetchMessages with changedSince when modseq changes',
() async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
r.fakeImap.uidValidityResult = 1000;
r.fakeImap.highestModSequenceResult = 55; // server advanced from 42 → 55
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'acc-1',
resourceType: 'IMAP:INBOX',
state: jsonEncode(
{'uidValidity': 1000, 'lastUid': 5, 'highestModSeq': 42}),
syncedAt: DateTime.now(),
),
);
// No new UIDs; server returns [5] for both UID search calls.
r.fakeImap.searchCallQueue = [[], [5]];
await r.emails.syncEmails('acc-1', 'INBOX');
expect(r.fakeImap.uidFetchMessagesCalls, 1);
expect(r.fakeImap.lastChangedSinceModSequence, 42);
// Checkpoint updated with new modseq.
final state = jsonDecode(
(await r.db.select(r.db.syncStates).get()).first.state)
as Map<String, dynamic>;
expect(state['highestModSeq'], 55);
});
test('flag refresh: updates flags in local DB', () async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 5,
receivedAt: DateTime(2024),
));
r.fakeImap.uidValidityResult = 1000;
r.fakeImap.highestModSequenceResult = 55;
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'acc-1',
resourceType: 'IMAP:INBOX',
state: jsonEncode(
{'uidValidity': 1000, 'lastUid': 5, 'highestModSeq': 42}),
syncedAt: DateTime.now(),
),
);
r.fakeImap.searchCallQueue = [[], [5]];
// Server says uid=5 is now \Seen.
r.fakeImap.uidFetchResults = [
buildEnvelopeMessage(uid: 5, flags: [r'\Seen']),
];
await r.emails.syncEmails('acc-1', 'INBOX');
final email = await r.emails.getEmail('acc-1:5');
expect(email!.isSeen, isTrue);
});
});
// ── Blob expiry (TTL) tests ───────────────────────────────────────────────────
group('blob expiry', () {
test('returns cached body when cachedAt is recent', () async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
));
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert(
emailId: 'acc-1:1',
textBody: const Value('cached text'),
cachedAt: Value(DateTime.now()),
),
);
final body = await r.emails.getEmailBody('acc-1:1');
expect(body.textBody, 'cached text');
expect(r.fakeImap.logoutCalled, isFalse);
});
test('re-fetches body when cachedAt is null (legacy row)', () async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
));
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert(
emailId: 'acc-1:1',
textBody: const Value('stale text'),
// cachedAt omitted → null
),
);
final msg = imap.MimeMessage.parseFromText(
'Subject: Hi\r\nContent-Type: text/plain\r\n\r\nfresh from IMAP',
);
msg.uid = 1;
r.fakeImap.fetchResults = [msg];
final body = await r.emails.getEmailBody('acc-1:1');
expect(body.textBody, contains('fresh from IMAP'));
expect(r.fakeImap.logoutCalled, isTrue);
});
test('re-fetches body when cachedAt is older than 7 days', () async {
final r = _makeReposWithFakes();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
));
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert(
emailId: 'acc-1:1',
textBody: const Value('old text'),
cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))),
),
);
final msg = imap.MimeMessage.parseFromText(
'Subject: Hi\r\nContent-Type: text/plain\r\n\r\nnew body',
);
msg.uid = 1;
r.fakeImap.fetchResults = [msg];
final body = await r.emails.getEmailBody('acc-1:1');
expect(body.textBody, contains('new body'));
expect(r.fakeImap.logoutCalled, isTrue);
});
});
// ── Failed mutations tests ────────────────────────────────────────────────────
group('failed mutations', () {
test('observeFailedMutations emits only rows with lastError set', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.pendingChanges).insert(PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'email',
resourceId: 'acc-1:10',
changeType: 'flag_seen',
payload: '{"seen":true}',
createdAt: DateTime.now(),
attempts: const Value(1),
lastError: const Value('network error'),
));
await r.db.into(r.db.pendingChanges).insert(PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'email',
resourceId: 'acc-1:11',
changeType: 'move',
payload: '{"dest":"Archive"}',
createdAt: DateTime.now(),
// lastError not set → pending, not failed
));
final mutations =
await r.emails.observeFailedMutations('acc-1').first;
expect(mutations, hasLength(1));
expect(mutations.first.resourceId, 'acc-1:10');
expect(mutations.first.changeType, 'flag_seen');
expect(mutations.first.lastError, 'network error');
});
test('discardMutation removes the row', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
final rowId = await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'email',
resourceId: 'acc-1:10',
changeType: 'delete',
payload: '{}',
createdAt: DateTime.now(),
attempts: const Value(3),
lastError: const Value('timeout'),
),
);
await r.emails.discardMutation(rowId);
final rows = await r.db.select(r.db.pendingChanges).get();
expect(rows, isEmpty);
});
test('retryMutation resets attempts and clears lastError', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
final rowId = await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'email',
resourceId: 'acc-1:10',
changeType: 'move',
payload: '{"dest":"Trash"}',
createdAt: DateTime.now(),
attempts: const Value(5),
lastError: const Value('connection refused'),
),
);
await r.emails.retryMutation(rowId);
final row = (await r.db.select(r.db.pendingChanges).get()).first;
expect(row.attempts, 0);
expect(row.lastError, isNull);
});
});
}
// ── SSE test helper ──────────────────────────────────────────────────────────
+17
View File
@@ -6,12 +6,14 @@ class FakeImapClient extends imap.ImapClient {
FakeImapClient() : super();
List<imap.MimeMessage> fetchResults = [];
List<imap.MimeMessage> uidFetchResults = [];
List<imap.Mailbox> listMailboxesResult = [];
List<int> searchUids = [];
/// If set, each [uidSearchMessages] call pops the first element.
/// Falls back to [searchUids] when the queue is empty or null.
List<List<int>>? searchCallQueue;
int uidValidityResult = 0;
int? highestModSequenceResult;
bool logoutCalled = false;
bool throwOnStatus = false;
int markSeenCalls = 0;
@@ -24,6 +26,8 @@ class FakeImapClient extends imap.ImapClient {
int appendCalls = 0;
String? lastAppendMailboxPath;
int createMailboxCalls = 0;
int uidFetchMessagesCalls = 0;
int? lastChangedSinceModSequence;
@override
Future<imap.Mailbox> selectMailboxByPath(
@@ -37,8 +41,21 @@ class FakeImapClient extends imap.ImapClient {
flags: [],
pathSeparator: '/',
uidValidity: uidValidityResult,
highestModSequence: highestModSequenceResult,
);
@override
Future<imap.FetchImapResult> uidFetchMessages(
imap.MessageSequence sequence,
String? fetchContentDefinition, {
int? changedSinceModSequence,
Duration? responseTimeout,
}) async {
uidFetchMessagesCalls++;
lastChangedSinceModSequence = changedSinceModSequence;
return imap.FetchImapResult(List.of(uidFetchResults), null);
}
@override
Future<imap.FetchImapResult> fetchMessages(
imap.MessageSequence sequence,
+10
View File
@@ -190,6 +190,16 @@ class FakeEmailRepository implements EmailRepository {
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();
@override
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
Stream.value([]);
@override
Future<void> discardMutation(int id) async {}
@override
Future<void> retryMutation(int id) async {}
}
// ---------------------------------------------------------------------------