Commit Graph
58 Commits
Author SHA1 Message Date
Thomas GüttlerandClaude Sonnet 4.6 650c7a70f5 docs: update DB-SYNC.md — all planned features now implemented
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>
2026-04-20 06:14:29 +02:00
Thomas GüttlerandClaude Sonnet 4.6 cf69bb71de test: concurrent IMAP + JMAP sync integration test against Stalwart
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>
2026-04-19 21:39:18 +02:00
Thomas GüttlerandClaude Sonnet 4.6 93ac5afbcf feat: conflict resolution hardening — server-wins policy, max-retry eviction
- 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>
2026-04-19 21:05:48 +02:00
Thomas GüttlerandClaude Sonnet 4.6 795001d268 feat: JMAP push via EventSource instead of polling
- 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>
2026-04-19 17:48:40 +02:00
Thomas GüttlerandClaude Sonnet 4.6 8d8dbc33db feat: JMAP send via EmailSubmission/set; role column on Mailboxes
- 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>
2026-04-19 17:41:21 +02:00
Thomas GüttlerandClaude Sonnet 4.6 7e34ca45de feat: JMAP body caching during sync and ifInState conflict detection
- 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>
2026-04-19 17:21:08 +02:00
Thomas GüttlerandClaude Sonnet 4.6 db548a7d8b feat: cross-protocol sync log — record success/failure for every sync cycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:09:13 +02:00
Thomas GüttlerandClaude Sonnet 4.6 8a0e09301b feat: JMAP Email/query pagination via calculateTotal
_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>
2026-04-19 17:01:14 +02:00
Thomas GüttlerandClaude Sonnet 4.6 cc12a53bc1 feat: JMAP getEmailBody via Email/get with bodyValues
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>
2026-04-19 16:55:25 +02:00
Thomas GüttlerandClaude Sonnet 4.6 bf66b2118e feat: IMAP incremental sync via sync_state checkpoints
_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>
2026-04-19 16:52:30 +02:00
Thomas GüttlerandClaude Sonnet 4.6 091c848d0e feat: IMAP durable outbound queue via pending_changes
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>
2026-04-19 16:48:13 +02:00
Thomas GüttlerandClaude Sonnet 4.6 4bf157c550 feat: IMAP sync all folders, not just INBOX
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>
2026-04-19 16:41:54 +02:00
Thomas GüttlerandClaude Sonnet 4.6 0797dd914b feat: JMAP outbound changes via pending_changes queue (Step 7)
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>
2026-04-19 16:25:44 +02:00
Thomas GüttlerandClaude Sonnet 4.6 559eb9a467 feat: JMAP background sync worker (Step 6)
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>
2026-04-19 16:22:09 +02:00
Thomas GüttlerandClaude Sonnet 4.6 2efeba9d2e feat: JMAP Email sync — full and incremental (Step 5)
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>
2026-04-19 16:19:03 +02:00
Thomas GüttlerandClaude Sonnet 4.6 f580cd0197 feat: JMAP Mailbox sync — full and incremental (Step 4)
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>
2026-04-19 16:15:34 +02:00
Thomas GüttlerandClaude Sonnet 4.6 0054322068 feat: add JmapClient session client (Step 3)
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>
2026-04-19 16:11:32 +02:00
Thomas GüttlerandClaude Sonnet 4.6 c6fb5154fb feat: add pending_changes table (Step 2 — outbound sync queue)
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>
2026-04-19 16:08:17 +02:00
Thomas GüttlerandClaude Sonnet 4.6 475ba34d28 feat: add sync_state table (Step 1 — DB foundation for incremental sync)
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>
2026-04-19 16:05:31 +02:00
Thomas Güttler 4cefc8aac3 deploy-android is working. 2026-04-19 15:30:42 +02:00
Thomas GüttlerandClaude Sonnet 4.6 9f12a1aa9c chore: remove completed 'Draft auto-save' from LATER.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:06:34 +02:00
Thomas GüttlerandClaude Sonnet 4.6 e1e95e97ee feat: draft auto-save in compose screen
- 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>
2026-04-18 19:06:02 +02:00
Thomas GüttlerandClaude Sonnet 4.6 2f1924be9c feat: email attachments — send, download and open
- 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>
2026-04-18 17:04:25 +02:00
Thomas GüttlerandClaude Sonnet 4.6 169e563e3d security: enforce encrypted connections; pre-commit uses check-fast
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>
2026-04-18 16:16:35 +02:00
Thomas GüttlerandClaude Sonnet 4.6 b144dba5ec feat: username field separate from email, try-connection button, JMAP auth verification
- 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>
2026-04-18 15:37:11 +02:00
Thomas GüttlerandClaude Sonnet 4.6 442c3c4087 feat: add-account wizard, edit account, inbox tap, connection status
- 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>
2026-04-18 15:13:47 +02:00
Thomas Güttler e2a87fc2b0 UI shows add account ... nice. 2026-04-18 14:00:46 +02:00
Thomas Güttler a423c4d4ca ... 2026-04-18 13:43:42 +02:00
Thomas GüttlerandClaude Sonnet 4.6 33d1e21bc9 perf: cut integration-ui test time from 250s to 28s
- 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>
2026-04-18 13:25:16 +02:00
Thomas Güttler c7a121e386 stalwart-dev/integration_ui_test.sh working! great! 2026-04-18 12:05:20 +02:00
Thomas Güttler f0d3d9e6a2 task run show a window. Empty, but at least a window. 2026-04-17 22:20:10 +02:00
Thomas Güttler 6621f434fa task check works, run not. 2026-04-17 12:17:51 +02:00
Thomas Güttler 9ce598d21c task check, working again. 2026-04-17 10:05:31 +02:00
Thomas GüttlerandClaude Sonnet 4.6 24482c7e4b Push coverage to 87%: remove 4 screens from excluded, add nav/search tests
- 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>
2026-04-16 15:29:40 +02:00
Thomas Güttler 99a46e1589 test. 2026-04-16 15:14:18 +02:00
Thomas Güttler c6be26623d better coverage. 2026-04-16 13:44:55 +02:00
Thomas Güttler 9f94310bde clean up 2026-04-16 13:29:34 +02:00
Thomas Güttler 20f180b76a misc. 2026-04-16 13:21:36 +02:00
Thomas Güttler 746f3c373c tests. 2026-04-16 11:48:37 +02:00
Thomas GüttlerandClaude Sonnet 4.6 7c000dcee5 Add IMAP search: server-side OR SUBJECT/TEXT, inline results in email list
- EmailRepository: add searchEmails(accountId, mailboxPath, query)
- EmailRepositoryImpl: UID SEARCH with OR SUBJECT/TEXT criteria,
  fetch ENVELOPE+FLAGS for matching UIDs
- EmailListScreen: toggle search bar in AppBar; submit triggers server
  search; results replace the stream list; ESC/back closes search
- Refactored list into _buildList() shared by stream and search views
- README/PLAN.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:13:33 +02:00
Thomas GüttlerandClaude Sonnet 4.6 6c27ad655b Complete UI gaps: move-to-folder, attachment indicators in list
- email_detail: add Move-to-folder button (bottom-sheet mailbox picker,
  excludes current folder, calls moveEmail on server)
- email_list: show amber star for flagged, paperclip for attachments
  in trailing area alongside date
- PLAN.md: mark phase 8 done
- README: add flag/move/attachment to working features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:11:11 +02:00
Thomas GüttlerandClaude Sonnet 4.6 4c6c741e00 UI gaps: account picker in compose, flag button in detail; update docs
- compose_screen: show From dropdown when >1 account, auto-select first
  account when none pre-selected (fixes silent failure on new mail)
- email_detail_screen: add flag/unflag star button with amber highlight
- PLAN.md: collapse completed phases, list remaining UI gaps
- README: fix stale "vendored" package reference, update platform table,
  add Working Features section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:17:14 +02:00
Thomas GüttlerandClaude Sonnet 4.6 0e2021f343 Fix integration tests: allow plain-text auth in Stalwart dev config
Stalwart 0.14 disables PLAIN/LOGIN on non-TLS ports by default:
- [imap.auth] allow-plain-text = true  → unblocks IMAP LOGIN
- [session.auth] mechanisms + allow-plain-text → advertises PLAIN/LOGIN on SMTP

Also fix test: SMTP authenticates as "alice" not "alice@localhost"
(Stalwart SMTP auth accepts username without domain, unlike IMAP login)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:08:05 +02:00
Thomas GüttlerandClaude Sonnet 4.6 818f66c738 Fix dart→flutter in CI; add pre-commit hook and install-hooks task
- ci.yml: dart run/dart test → flutter pub run/flutter test (all 3 jobs)
- hooks/pre-commit: runs flutter analyze + flutter test before every commit
- Taskfile: add install-hooks task (copies hooks/pre-commit → .git/hooks/)
- LATER.md: remove resolved items (enough_mail fork, pre-commit, GH CI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:55:15 +02:00
Thomas GüttlerandClaude Sonnet 4.6 da38948652 Fix dart→flutter test in integration script; add task build-android
- stalwart-dev/test.sh: dart test → flutter test (project requires Flutter SDK)
- Taskfile: add build-android task (flutter build apk --release)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:43:24 +02:00
Thomas GüttlerandClaude Sonnet 4.6 427ba5d0ac PLAN.md: simplify phase 0 — enough_mail comes from pub.dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:38:54 +02:00
Thomas GüttlerandClaude Sonnet 4.6 2f93ae3638 Use upstream enough_mail 2.1.7 from pub.dev
No fork needed — our changes were correct API usage, not workarounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:38:46 +02:00
Thomas GüttlerandClaude Sonnet 4.6 8a37ca129f PLAN.md: mark phase 0a (enough_mail fork) as Done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:35:04 +02:00
Thomas GüttlerandClaude Sonnet 4.6 79ee498879 Switch enough_mail from vendored path to forked git dependency
- pubspec.yaml: path: packages/enough_mail → git: guettli/enough_mail
  pinned to SHA 25320ada (same version as the vendored copy)
- Remove packages/enough_mail/ (170 files, 38k lines) from this repo
- Fix .gitignore: restore comment, add coverage/ (generated by --coverage)
- PLAN.md: document phase 0a

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:34:55 +02:00
Thomas GüttlerandClaude Sonnet 4.6 71952ed36b Fix: vendor enough_mail as regular files instead of gitlink
The directory was tracked as a mode-160000 gitlink (bare submodule
reference) without a .gitmodules entry, causing 'has no commit checked
out' errors on commit. Re-added as ordinary tracked files so the
vendored copy is fully part of this repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:52:01 +02:00