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:
co-authored by
Claude Sonnet 4.6
parent
650c7a70f5
commit
d5a5c7fbe3
@@ -1,6 +1,10 @@
|
||||
# Later
|
||||
|
||||
Push to guettli@thomas-guettler via ssh+git
|
||||
LINTING.md
|
||||
|
||||
---
|
||||
|
||||
Sieve: JMAP, easy. Per IMAP...
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user