All tests that needed real IMAP/SMTP now live in integration tests.
The max-attempts test uses an inline failing lambda, not the fake client.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add lcov to nix flake (required for flutter --merge-coverage)
- stalwart-dev/test.sh: collect and merge coverage when unit baseline exists
- run_unit_tests.sh: remove inline coverage check (now in dedicated task)
- Taskfile: add coverage task; check runs test → integration → coverage
sequentially so the gate sees combined unit + integration data
- check-fast (pre-commit) omits coverage gate since integration tests
don't run there; full gate runs only in task check
- Drop two untestable fake-only tests (UID-validity reset, malformed envelope)
- Coverage threshold restored to 80% (84% with merged data)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace full-sync fetchMessages(1:*) with UID SEARCH ALL + UID FETCH
so every message gets a reliable UID on all servers
- Guard CONDSTORE select on server capability to avoid BAD from
servers that do not advertise CONDSTORE/QRESYNC
- Add SyncLogEntry model + observeSyncLogs stream to SyncLogRepository
- Add SyncLogScreen with per-entry duration/error display
- Wire history icon in SettingsScreen → /accounts/:id/sync-log route
- Fix FakeImapClient to expose initialized serverInfo via field override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: flutter test ran all 3 integration test files in parallel
against the same Stalwart instance. Concurrent SMTP/IMAP from
email_repository_imap_test and concurrent_sync_test caused SMTP rate
limiting (4th send hung for ~27s) and flushPendingChanges race failures.
Fixes:
- stalwart-dev/test.sh: add --concurrency=1 so test files run serially
- concurrent_sync_test: reduce timeout 2 min → 30 s (tests now pass in ~2s)
- imap_client_factory + test helpers: set defaultResponseTimeout=20s on
ImapClient so individual IMAP commands never block indefinitely
- jmap_client: reduce HTTP call timeout 30 s → 10 s (local server; keeps
stacked-timeout total well below any reasonable per-test limit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>
Move JMAP send, push, and conflict-resolution items from Next steps
into Implemented features. Replace the next-steps section with
optional future work (CONDSTORE, blob expiry, UI for stuck mutations).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sends 4 emails (2 per direction) between alice (IMAP) and bob (JMAP),
then concurrently syncs both accounts and verifies the in-memory Drift
DB cache has no duplicates and contains all expected rows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Check notUpdated/notDestroyed per-item errors in Email/set; throw
JmapSetItemException for permanent failures (notFound, forbidden) so
they are discarded immediately rather than retried
- Add _maxChangeAttempts=5 constant; _recordChangeError() evicts the
pending-change row when attempts reach the limit, preventing unbounded
queue growth from transient errors
- Both IMAP and JMAP flush paths now use _recordChangeError() consistently
- Document server-wins conflict-resolution policy in DB-SYNC.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add watchJmapPush(accountId, password) to EmailRepository; IMAP and
JMAP-without-push return Stream.empty() so callers fall through to polling
- EmailRepositoryImpl opens an SSE (text/event-stream) connection to the
server's eventSourceUrl; yields void on each StateChange event; properly
cancellable via StreamController.onCancel
- _JmapAccountSync._wait() subscribes to watchJmapPush and races it against
the 30 s poll timer and the stop signal — whichever fires first unblocks
the next sync cycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- sendEmail dispatches on account type: IMAP keeps SMTP+APPEND path,
JMAP chains Email/set create + EmailSubmission/set in one API call
- Sent mailbox looked up by role='sent' from local DB so sent mail lands
in the right folder
- JmapClient gains uploadUrl/eventSourceUrl/capabilities from session,
supportsSubmission getter, withSubmission flag on call(), and uploadBlob()
for attachment upload before send
- Mailboxes table gains nullable role column (schema v8); _upsertJmapMailboxes
persists role from JMAP Mailbox/get response
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Include bodyValues/textBody/htmlBody/attachments in every Email/get call
during syncEmails; _upsertJmapEmails writes to email_bodies so first open
is instant even for freshly synced messages
- Extract _parseJmapBody helper shared by sync path and on-demand fetch
- Add JmapStateMismatchException; _applyPendingChangeJmap passes ifInState
and returns newState; on stateMismatch the local checkpoint is cleared so
the next cycle does a full re-sync before retrying the mutation
- Update DB-SYNC.md to reflect what has been implemented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_jmapFullEmailSync now loops with position offset until all emails are
fetched. Each iteration sends calculateTotal=true; if the accumulated
position < total, another page is requested. The Email state from the
first page is saved so incremental sync picks up exactly from there.
Servers that omit total (non-RFC 8620) are handled gracefully: the loop
stops after the first page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getEmailBody now dispatches on account type. For JMAP accounts it calls
Email/get with fetchHTMLBodyValues and fetchTextBodyValues, extracts the
first text and HTML body part via partId references, and caches the
result in email_bodies — same as the IMAP path.
Before this change, JMAP body requests fell through to the IMAP path
which would fail for accounts without IMAP credentials.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_syncEmailsImap now stores {uidValidity, lastUid} per mailbox in the
sync_state table after each full sync. Subsequent syncs only fetch
UIDs newer than lastUid (UID N+1:*) and then do an ALL search to
reconcile remote deletions — avoiding a full re-download on every poll.
When UID validity changes the stale local emails are discarded and a
full re-sync is performed automatically.
fake_imap: add uidValidityResult + searchCallQueue so tests can feed
distinct responses to consecutive uidSearchMessages calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setFlag/moveEmail/deleteEmail for IMAP accounts now enqueue to
pending_changes (with uid + mailboxPath in the payload) and apply an
optimistic local update, instead of calling the IMAP server directly.
flushPendingChanges dispatches on account type: JMAP uses the existing
Email/set path; IMAP opens one connection and drains all queued changes.
Connection failure marks every queued row with an incremented attempt
count so retries work correctly.
_AccountSync._sync() now calls flushPendingChanges before syncing so
queued mutations are delivered on the next poll.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move ImapConnectFn typedef to imap_client_factory so it can be shared.
Inject it into AccountSyncManager/_AccountSync so tests can substitute a
no-op instead of hitting a real IMAP server.
_AccountSync._sync() now iterates all mailboxes from the repository after
syncMailboxes, mirroring the JMAP loop that was already in place.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
For JMAP accounts, setFlag/moveEmail/deleteEmail now write to the
pending_changes table instead of making direct server calls, enabling
offline-first mutation with durable retries.
flushPendingChanges() drains the queue at the start of each JMAP
sync cycle via Email/set (flag updates use keyword patches; move
updates mailboxIds; delete uses Email/set destroy). On failure the
attempt count and last error are recorded; the change remains queued.
Local DB is updated optimistically on mutation so the UI responds
immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AccountSyncManager now starts a _JmapAccountSync loop for JMAP accounts
alongside the existing _AccountSync for IMAP accounts.
_JmapAccountSync:
- Syncs mailboxes then emails for each known mailbox per cycle.
- Polls every 30 seconds (no IDLE for JMAP; EventSource deferred).
- Reuses the same exponential backoff (5–300 s) on failure.
- stop() interrupts the poll wait immediately via a Completer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EmailRepositoryImpl.syncEmails now dispatches on account type.
For JMAP accounts:
- First run: Email/query (filtered by mailbox, limit 500) + Email/get
via back-reference → upsert emails, persist state.
- Subsequent runs: Email/changes → fetch new/updated via Email/get,
delete destroyed rows, update state in sync_state.
Maps JMAP keywords ($seen, $flagged), mailboxIds, addresses, and
hasAttachment to the existing Emails table. uid stored as 0 for
JMAP emails (unused; JMAP operations go through Email/set in Step 7).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MailboxRepositoryImpl.syncMailboxes now dispatches on account type.
For JMAP accounts:
- First run: Mailbox/get → upsert all mailboxes, persist state.
- Subsequent runs: Mailbox/changes → fetch new/updated via Mailbox/get,
delete destroyed rows, update state in sync_state.
path stores the JMAP mailbox ID so Email rows can reference it via
mailboxPath consistently with the IMAP convention.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parses the JMAP Session object (RFC 8620 §2): fetches GET {jmapUrl},
extracts apiUrl and primary accountId, and wraps API calls via
call(methodCalls) which POSTs to apiUrl with Basic Auth.
Handles relative apiUrl, primaryAccounts fallback, and top-level
JMAP error responses. Covered by unit tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Protocol-agnostic queue for local mutations (flag, move, delete) that
need to be sent to the server. Enables offline-first behaviour: changes
are written here first and drained by the sync worker. Tracks attempt
count and last error for durable retries.
DB schema bumped to v6.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Protocol-agnostic checkpoint table stores one state token per
(account_id, resource_type). JMAP uses the opaque state string from
Mailbox/get and Email/get; IMAP will use a JSON checkpoint per mailbox.
DB schema bumped to v5.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Drafts table (schema v4 migration) with autoincrement id,
accountId, replyToEmailId, to/cc/subject/body text, updatedAt
- DraftRepository interface + DraftRepositoryImpl (Drift)
- draftRepositoryProvider wired in di.dart
- ComposeScreen debounces saves (2 s after last keystroke), shows
transient "Saved" indicator, restores the latest matching draft on
open when no prefill fields are provided, deletes draft on send
- 6 new unit tests for DraftRepositoryImpl
- New widget test verifying draft restore behaviour
- FakeDraftRepository added to widget test helpers
- draft_repository.dart added to coverage no-code exclusion list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add file_picker and open_file dependencies
- EmailDraft gains attachmentFilePaths; EmailAttachment gains fetchPartId
- sendEmail attaches files via MessageBuilder.addFile()
- downloadAttachment fetches the specific MIME part from IMAP, caches to
local filesystem; subsequent calls return the cached file without a
network round-trip
- ComposeScreen: attach-file button + removable attachment list
- EmailDetailScreen: per-attachment download/open button with spinner
- 3 new unit tests covering send-with-attachment, download, and cache hit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IMAP/SMTP encryption:
- connectImap throws if account.imapSsl is false
- connectSmtp removes STARTTLS plaintext fallback; startTls failure is fatal
- Remove IMAP SSL/TLS toggle from add/edit account screens (always SSL)
- UI shows "IMAP (SSL/TLS)" section label to communicate the requirement
Pre-commit speed:
- Add check-fast task (analyze + unit + widget, no build-linux, no integration)
- pre-commit hook now runs task check-fast instead of task check
- task check remains the full suite for manual/CI use
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Account model gains `username` field (default empty → falls back to email then local-part)
- ConnectionTestService returns the effective username that succeeded; tries email then local-part when blank
- JMAP connection probe uses Basic-auth GET to /.well-known/jmap (401/403 = auth failure)
- IMAP/SMTP factory passes explicit username parameter
- Add/edit account screens show username field and "Try connection" button
- EditAccountScreen reuses stored password when no new password is entered
- Unit tests for ConnectionTestServiceImpl (IMAP + JMAP paths, fallback logic)
- Fix unit test lambda signatures for updated ImapConnectFn/SmtpConnectFn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add account wizard: email-first flow with JMAP/IMAP auto-detection
via well-known URLs; falls back to manual type selection
- Fix JMAP connection probe: GET session URL with Basic auth instead
of the API endpoint, so 401 reliably signals bad credentials
- Account list tile: tap → open INBOX directly; popup menu for
all mailboxes / edit / delete (with confirmation dialog)
- Show account type (JMAP/IMAP) and async connection status per tile:
spinner while checking, green check on success, red error on failure
- Add EditAccountScreen: edit name, password, server settings; runs
connection test only when password is changed
- Fix GTK window initialisation order so app starts with correct size
- Fix 42 lint issues (avoid_redundant_argument_values,
unnecessary_non_null_assertion, unawaited_futures)
- 147 tests, 87% coverage, task check green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix HOME override that caused FVM to re-download 220MB Flutter SDK on
every run; use XDG_DATA_HOME instead to isolate app data without
touching HOME
- Switch DB path from getApplicationDocumentsDirectory() to
getApplicationSupportDirectory() so XDG_DATA_HOME isolation works and
stale accounts don't leak between test runs
- Replace fixed pump(5s/3s) waits with pumpUntil() polling at 200ms so
tests stop waiting as soon as the UI is ready (23s of dead wait → 8s)
- Add timing instrumentation (ts() in shell, _log()/Stopwatch in Dart)
- Fix CI integration-ui job: was mixing subosito flutter with fvm flutter;
now uses fvm consistently with actions/cache for ~/.fvm, ~/.pub-cache,
and build/linux
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- run_unit_tests.sh and CI now run unit + widget tests together for coverage
- Removed account_list, email_list, mailbox_list, settings screens from
_excluded (all ≥70% when measured with widget tests)
- Added tests: mailbox tile tap, account tile tap, empty-state button,
settings Remove confirmation, email search submit/results/sync/edit
- FakeEmailRepository accepts searchResults for testing search paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>