Files
sharedinbox/SYNC.md
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

218 lines
8.6 KiB
Markdown

# Email Sync Architecture
This document describes the full lifecycle of an email action — from the moment the user taps
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
queue, exponential backoff, and the undo/cancel mechanism.
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
---
## 1. Components
| Component | File | Role |
|-----------|------|------|
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
---
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
```
User taps "Mark as read"
EmailRepository.setFlag(id, seen: true)
├─ 1. Write optimistic update to local DB
│ emails.is_seen = true
└─ 2. Insert row into pending_changes
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
(IMAP: includes uid + mailboxPath for the STORE command)
(JMAP: includes just the flag map for Email/set)
[UI immediately reflects the change via Drift's reactive streams]
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
_SyncLoop._flush() / flushPendingChanges()
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
If stateMismatch → clear checkpoint → full re-sync
pending_changes row deleted on success
(on permanent error: retry count incremented; evicted after 5 failures)
```
---
## 3. IMAP sync loop
The IMAP loop runs one coroutine per account (`_AccountSync`):
```
start()
[forever loop]
├─ flushPendingChanges() ← drain outbound queue first
├─ syncMailboxes() ← detect new/removed mailboxes
├─ for each mailbox:
│ syncEmails() ← incremental: fetch only UIDs > lastUid
│ deletion reconciliation: remove rows
│ whose UID is absent from the server
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
│ or syncNow() signal from UI
└─ repeat
```
**Incremental sync checkpoint**`sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
folder is re-scanned and the checkpoint is reset.
**IMAP move remap** — IMAP UIDs are mailbox-scoped, so a moved message gets a new UID in
its destination folder. When a `move`/`snooze`/`unsnooze` change is flushed, the local row
id (`accountId:mailboxPath:uid`) is rewritten in place to point at the new UID. The new
UID is taken from the RFC 4315 `COPYUID` response code returned by `MOVE`; if the server
does not advertise `UIDPLUS`, a `UID SEARCH HEADER Message-ID …` in the destination
mailbox is used as a fallback. `email_bodies`, `threads`, `pending_changes`, and
`undo_actions` rows that reference the old id are updated atomically so cached bodies and
pending undo operations keep tracking the same physical message. Deletion reconciliation
also skips rows whose move is still queued, so the optimistic local move never gets
wiped mid-flight.
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
---
## 4. JMAP sync loop
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
```
start()
[forever loop]
├─ flushPendingChanges() ← Email/set for queued mutations
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
├─ for each mailbox:
│ syncEmails() ← Email/query + Email/get (first run)
│ Email/changes (subsequent runs, state token)
└─ _wait()
├─ If server advertises eventSourceUrl: subscribe to SSE push
│ wake on "Email" change event
└─ Otherwise: sleep 30 s (poll fallback)
```
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
a full re-fetch.
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
---
## 5. Exponential backoff
Both loops share the same backoff policy:
| Outcome | Backoff |
|---------|---------|
| Sync succeeded | Reset to 5 s |
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
on the next successful cycle.
The last error message is written to `sync_log` and surfaced in the UI via
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
---
## 6. Pending-change queue
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
reaches the server:
| Column | Description |
|--------|-------------|
| `id` | Auto-increment primary key |
| `email_id` | The email being mutated |
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
| `payload` | JSON-encoded protocol-specific arguments |
| `retry_count` | Incremented on each failed flush attempt |
| `created_at` | For ordering and debug |
**Optimistic UI** — every mutation writes the local change first, then inserts into
`pending_changes`. The Drift reactive stream delivers the update to the UI before
the network round-trip completes.
**Conflict resolution** — the server always wins. On the next sync cycle the server's
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
---
## 7. Undo and cancel
When the user triggers an undoable action the UI calls:
```
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
```
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
A `SnackBar` with an **Undo** button appears for a few seconds.
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
```
1. cancelPendingChange(id, originalType)
└─ Deletes the pending_changes row if it has not been flushed yet.
Returns true if cancelled, false if the server already processed it.
2. If the email row was hard-deleted (DELETE action):
restoreEmails([original])
└─ Re-inserts the row with its pre-deletion state,
placed in the correct mailbox (source if cancelled, dest otherwise).
3. moveEmail(id, sourceMailboxPath)
└─ Optimistic local move back to the original folder.
If step 1 returned false (already sent to server), this enqueues
a reverse-move in pending_changes so the server move is undone too.
4. If step 1 returned true (cancelled before flush):
cancelPendingChange(id, 'move')
└─ The reverse-move from step 3 is redundant; remove it.
```
The net result is: if the mutation was still in the queue it is silently cancelled with no
server round-trip; if it had already been flushed, a compensating move is queued.
---
## 8. Key invariants
- **Order**: pending changes are flushed before syncing. This prevents the server from
overwriting an optimistic local state that the server hasn't seen yet.
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
deleted only after the server acknowledges the change.
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
visible in the UI (Settings → Failed mutations).
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
through `core/` interfaces. The `check-layers` Taskfile task enforces this.