Files
sharedinbox/test/unit/fake_imap.dart
T
agentloopandClaude Opus 4.7 0141d86361 fix(imap): remap local id to new UID after MOVE so caches survive
IMAP UIDs are mailbox-scoped, so MOVE assigns a fresh UID in the
destination folder. The flush previously discarded the response
from `client.uidMove(...)`, so the local row kept the *source*
UID while its `mailbox_path` already pointed at the destination.
Two things broke:

- Deletion reconciliation, which runs per mailbox and compares
  local UIDs to the server's `ALL` search result, would not find
  the source UID in the destination mailbox and wipe the row —
  taking the cached body and queued undo with it.
- `UndoLog` rows kept referencing the old `accountId:mailbox:uid`
  id, so undo had to fall back to a Message-ID lookup just to
  rediscover the moved message.

The fix captures the RFC 4315 `COPYUID` response code that
modern `UIDPLUS` servers attach to `MOVE`/`COPY` (already exposed
as `GenericImapResult.responseCodeCopyUid` in `enough_mail`).
When that's missing — i.e. the server doesn't support UIDPLUS —
we fall back to `UID SEARCH HEADER Message-ID …` in the
destination mailbox. Either way the local id is rewritten in
place to `accountId:destMailbox:newUid` and the cascading
`email_bodies`, `threads`, `pending_changes`, and `undo_actions`
references are updated in the same transaction.

`_reconcileDeletedImap` now also skips rows whose
`move`/`snooze`/`unsnooze` is still queued in `pending_changes`,
so the optimistic local move can't be wiped between the
optimistic write and the server flush.

Closes #539

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 13:19:04 +00:00

128 lines
3.8 KiB
Dart

import 'package:enough_mail/enough_mail.dart' as imap;
// ignore: implementation_imports
import 'package:enough_mail/src/private/util/client_base.dart'
show ConnectionInfo;
/// Minimal fake IMAP client used by connection_test_service_test.dart.
/// Only overrides what is strictly needed to avoid real network calls.
class FakeImapClient extends imap.ImapClient {
FakeImapClient() : super();
@override
final imap.ImapServerInfo serverInfo = imap.ImapServerInfo(
const ConnectionInfo('fake.host', 993, isSecure: true),
)..capabilities = [];
@override
Future<dynamic> logout() async {}
}
/// Spy IMAP client that records snooze-related operations and succeeds silently.
class SnoozeSpyImapClient extends FakeImapClient {
SnoozeSpyImapClient({
this.copyUidValidity,
this.copyUidSourceToTarget = const {},
this.searchResults = const {},
});
String? selectedMailbox;
String? createdMailbox;
String? movedToMailbox;
String? lastSearchCriteria;
/// When non-null, `uidMove` returns a `COPYUID` response code built from
/// these mappings (sourceUid → destinationUid) for the moved sequence.
final int? copyUidValidity;
final Map<int, int> copyUidSourceToTarget;
/// Maps a `UID SEARCH HEADER Message-ID …` search criteria (the literal
/// IMAP atom incl. quotes) to the UIDs the fake should return.
final Map<String, List<int>> searchResults;
imap.Mailbox _fakeMailbox(String path) => imap.Mailbox(
encodedName: path,
encodedPath: path,
pathSeparator: '/',
flags: [],
);
@override
Future<imap.Mailbox> selectMailboxByPath(
String path, {
bool enableCondStore = false,
imap.QResyncParameters? qresync,
}) async {
selectedMailbox = path;
return _fakeMailbox(path);
}
@override
Future<imap.Mailbox> createMailbox(String path) async {
createdMailbox = path;
return _fakeMailbox(path);
}
@override
Future<imap.StoreImapResult> uidStore(
imap.MessageSequence sequence,
List<String> flags, {
imap.StoreAction? action,
bool? silent,
int? unchangedSinceModSequence,
}) async =>
imap.StoreImapResult();
@override
Future<imap.GenericImapResult> uidMove(
imap.MessageSequence sequence, {
imap.Mailbox? targetMailbox,
String? targetMailboxPath,
}) async {
movedToMailbox = targetMailboxPath;
final result = imap.GenericImapResult();
if (copyUidValidity != null && copyUidSourceToTarget.isNotEmpty) {
final sources = sequence.toList();
final mapped = sources
.where(copyUidSourceToTarget.containsKey)
.map((uid) => copyUidSourceToTarget[uid]!)
.toList();
if (mapped.isNotEmpty) {
final src = sources.join(',');
final dst = mapped.join(',');
result.responseCode = 'COPYUID $copyUidValidity $src $dst';
}
}
return result;
}
@override
Future<imap.SearchImapResult> uidSearchMessages({
String searchCriteria = 'UNSEEN',
List<imap.ReturnOption>? returnOptions,
Duration? responseTimeout,
}) async {
lastSearchCriteria = searchCriteria;
final hits = searchResults[searchCriteria] ?? const <int>[];
final result = imap.SearchImapResult()
..matchingSequence = imap.MessageSequence.fromIds(hits, isUid: true);
return result;
}
@override
Future<imap.FetchImapResult> uidFetchMessages(
imap.MessageSequence sequence,
String? fetchContentDefinition, {
int? changedSinceModSequence,
Duration? responseTimeout,
}) async =>
const imap.FetchImapResult([], null);
}
/// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService.
class FakeSmtpClient extends imap.SmtpClient {
FakeSmtpClient() : super('fake.host');
@override
Future<imap.SmtpResponse> quit() async => imap.SmtpResponse(const []);
}