- Add Dagger to flake.nix
- Create Dagger module in ci/ with Flutter build/test logic
- Update .forgejo/workflows/ci.yml to use Dagger
- Move Android emulator tests to separate disabled workflow
- Add .daggerignore to exclude host junk
The standalone "Check mocks are up to date" step ran build_runner AOT
compilation separately, then task check ran it again (check-mocks is
already a dep of check). The double invocation caused the build_runner
AOT compile to receive SIGTERM on the CI runner in run 4027578.
task check already verifies mocks via its check-mocks dep, so the
standalone step is redundant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Track how long each mailbox takes to sync and display it in the
sync log expanded view (e.g. "2 new · 5 up-to-date · 1.3s").
- Add optional `duration` field to `MailboxSyncStats`
- Capture per-mailbox start/end time in both IMAP and JMAP sync loops
- Store as `duration_ms` in `sync_log_mailboxes` (schema v30 migration)
- Read back and reconstruct `Duration` in repository
- Show timing alongside fetch/skip counts in per-mailbox breakdown
- Extract `_fmtDuration` helper, reuse for the existing total duration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add "Force full sync" popup menu item below "Verify sync health" in the
per-account menu on the account list screen, with a confirmation dialog.
Remove the button and handler from the edit account screen.
HTML emails with black text became unreadable when viewed in dark mode
because the WebView inherited a dark background from the system theme.
Inject `color-scheme: light` CSS + meta tag so the WebView always renders
email content on a white background, regardless of the device theme.
Extracts `buildEmailHtml()` as a `@visibleForTesting` top-level function
and adds unit tests to cover the light-mode enforcement and CSP logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace getTemporaryDirectory() + OpenFilex.open() with
getDownloadsDirectory() (fallback to temp) so the .eml file lands in
the public Downloads folder instead of triggering Android's
"open with" dialog.
- Show a SnackBar with the saved path after download instead of
launching a file viewer.
- Display the email size (via fmtSize) at the top of the Raw Email
dialog, above the scrollable content.
- Add widget test covering the size display in the Raw Email dialog.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Searching for "foo" now finds "foobar" (prefix of a word) but not
"blafoo" (suffix). The FTS5 query already used the foo* prefix form;
this commit extends the same semantics to folder-name and address
matching in the search screen, replacing contains() with a
word-boundary regex check. Tests added for all three paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
checkNow() previously delegated to _runAll(), which gated each
account on the _running flag (only true after start() is called).
This meant the manual "Verify sync health" action silently did nothing
if start() had not yet been called, or in any context where the
periodic runner was not active (e.g. widget tests).
Fix: checkNow() now iterates accounts directly and calls
_runForAccount() with force:true, bypassing the _running guard.
The guard is still respected during periodic runs for graceful
shutdown.
Adds three unit tests that reproduce the bug and verify the fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The deploy steps in build-linux and deploy-playstore already use
continue-on-error: true when SSH secrets may be absent, but
publish-website did not — causing a hard failure when SSH_USER is unset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The builds page at /builds/ was empty because generate-build-history
only ran inside deploy-playstore; if that job failed early (e.g. Play
Store secrets not configured) the website was never updated, and the
build-linux job never triggered a website update at all.
Changes:
- generate_build_history.py: extend to cover Linux tarballs in addition
to Android APKs, capped at MAX_BUILDS_PER_PLATFORM (30) each
- Taskfile: add website-publish task (generate-build-history +
website-deploy), exclude *.tar.gz from rsync, update descriptions
- .forgejo/workflows/ci.yml: add publish-website job that waits for
both build-linux and deploy-playstore (using always() so it runs
even when deploy-playstore fails), then removes the duplicate
generate/deploy steps from deploy-playstore
- .github/workflows/ci.yml: add deploy job that deploys Linux build,
generates build history, builds Hugo site, and rsyncs to server
- .gitignore: ignore website/content/builds/_index.md (generated),
Python __pycache__, and widget test failure screenshots
- stalwart-dev/integration_ui_test.sh: use ${USER:-$(id -un)} for
robustness in environments where USER is unset
- scripts/test_generate_build_history.py: unit tests for parse_builds
and render_entries covering both platforms
Generated content (builds/_index.md and per-day pages) is not tracked
in git; it is produced at CI time and rsynced to the server.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reuse the same Sieve UI for both server-side (ManageSieve/JMAP) and local
email filters. Both filter sets are stored and managed independently.
Changes:
- Add LocalSieveScripts table (DB schema v29) to store local Sieve scripts
- Add LocalSieveRepository with full CRUD and activate-script support
- Add isLocal param to SieveScriptsScreen and SieveScriptEditScreen; each
screen shows a banner explaining whether scripts run on the server or device
- Add routes /accounts/:id/sieve/local and /accounts/:id/sieve/local/edit
- Split "Email filters" account menu entry into "Server email filters" and
"Local email filters" (local is always available, server requires ManageSieve)
- Wire up localSieveRepositoryProvider in DI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without `< /dev/null`, claude detects the tmux PTY as stdin and blocks
waiting for user input that never arrives (the PTY never sends EOF).
The 3-second stdin-timeout only fires for pipe stdin, not TTY stdin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Filter deleted emails locally in _batchDelete so the pop-back fires
immediately instead of waiting for the IMAP server to catch up.
- Add _openSearchResultAndRefresh / _refreshSearchAndPopIfEmpty so that
returning from EmailDetailScreen after deleting the last match also
pops EmailListScreen back to the caller.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace bare subprocess.Popen with `tmux new-session -d` so each agent
runs in a detached tmux session that inherits the tmux server's environment
(including ANTHROPIC_API_KEY / keychain access, which cron's minimal env
lacks — the root cause of intermittent empty log files).
- Track agents by tmux session name instead of PID; age is derived from the
state-file `started_at` timestamp rather than /proc/<pid>/stat.
- `_kill_agent` terminates via `tmux kill-session`; backward compat preserved
for old state files that stored a `pid`.
- Operators can now `tmux attach -t issue-<N>` to watch live output, or
`claude --resume issue-<N>` to continue the conversation afterward.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs prevented snoozing in a brand-new IMAP/JMAP account:
- IMAP flush read `payload['mailboxPath']` which doesn't exist in snooze
payloads (they use 'src'); selecting the wrong (null) mailbox caused the
operation to fail. Now uses `payload['mailboxPath'] ?? payload['src']`.
- JMAP flush had no path to create the Snoozed mailbox when the folder
didn't already exist on the server. Flush now calls `Mailbox/set` to
create it whenever `dest == 'Snoozed'` (the sentinel used when the folder
was absent at enqueue time), then substitutes the real JMAP mailbox ID.
Tests added for both code paths using a spy IMAP client and a mock JMAP
HTTP client respectively.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Export account screen (QR code + copy-to-clipboard) and Import
account screen (paste JSON code) so users can transfer IMAP/JMAP
account configuration to another device without re-entering every field.
- Account list popup: "Export account" opens a QR code with a password
warning and a copy-code button.
- Add Account screen: "Import account" button opens the import flow
where pasting the exported JSON pre-fills the account and one tap
saves it with a fresh generated ID.
- New routes: /accounts/:id/export and /accounts/import.
- Widget tests cover export display, import parsing, validation,
and the happy-path save-and-navigate flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the user searches in a mailbox, selects all results, and deletes
them, re-evaluate the search. If no results remain and there is a
previous screen in the navigation stack, pop back to it instead of
clearing the search and showing the regular inbox.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The JMAP body-fetch path never requested or stored `bodyStructure`, so
`body.mimeTree` was always null for JMAP accounts — causing Show Mail
Structure to show nothing.
Fix: include `bodyStructure` in the JMAP `Email/get` request and convert
it to the same JSON format used by the IMAP path via the new
`_jmapBodyStructureToJson` helper. The parsed tree is persisted in the
DB and returned from `getEmailBody`, so the cached round-trip also works.
Tests added:
- Unit: JMAP getEmailBody populates mimeTree from bodyStructure and
survives the cache round-trip; null when bodyStructure is absent.
- Widget: Show Mail Structure dialog displays all MIME parts when
mimeTree is present; snackbar appears when mimeTree is null.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cron runs with a minimal environment that doesn't include ~/.nix-profile/bin,
causing every invocation to crash with FileNotFoundError on 'tea'.
Closes#93
Polls Codeberg CI and State/Ready issues every 10 minutes, launching
Claude Code agents for CI fixes and issue work, with PID-based liveness
tracking and automatic timeout after 1 hour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a MimePart tree model, parses it from the IMAP BODYSTRUCTURE
when fetching the email body, caches it in a new mime_tree_json column
(schema v28), and exposes a 'Show Mail Structure' overflow menu item
that renders the indented tree (content-type, filename, size, encoding)
in an AlertDialog alongside the existing headers dialog.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Emails with multipart/related structure reference embedded images via
cid: URIs. The WebView's CSP only allows data:/blob: sources, so those
images were never shown. injectInlineImages() now replaces each cid:
reference with a data: URI using the decoded bytes from the MIME tree,
both for double-quoted and single-quoted src attributes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes for the UndoLog:
1. Don't delete the original undo log entry when undo is performed.
The entry stays in the log alongside the new inverse action, so
the user can retry the undo if it was silently reverted by an
IMAP sync.
2. Fix IMAP UID mismatch: after an IMAP move is applied on the server
the email gets a new UID in the destination folder. The undo service
now looks up the email by its RFC 2822 Message-ID when the original
row is gone, so the reverse-move pending change carries the correct
UID and actually succeeds on the server.
Add findEmailByMessageId to EmailRepository interface and impl.
Add a regression test that simulates the UID change scenario.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After deleting all selected emails from a search view, re-run the
search query. If no emails match any more, clear the search bar so
the user returns to the normal thread list view instead of seeing
a stale list of already-deleted messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add check-mocks task that re-runs build_runner and fails if any
*.mocks.dart file differs from what is committed. Wired into
check-fast (pre-commit) and added as an early CI step so stale
mocks are caught before the full test suite runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of reconstructing the message from the local DB, fetch the
original bytes live from IMAP (BODY.PEEK[]) or JMAP (Email/get blobId
→ downloadBlob) so the view shows the true unmodified message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new popup menu item below "Show Mail Headers" that displays the
email in RFC-style ASCII format (headers + plain-text body), with a
Copy-to-clipboard button and a Download button that saves a .eml file
and opens it via the system file handler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Swap the flutter_html renderer for a webview_flutter-based widget that
enforces strict security by default: scripts blocked via CSP
(script-src 'none'), remote images opt-in, and every link click routed
through a confirmation dialog that bolds the registered domain for
phishing detection. Links open in the system browser via url_launcher.
On Linux (no webview_flutter platform support) the widget falls back to
plain text extracted via the existing htmlToPlain() utility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Builds and deploys Windows once a day (02:00 UTC) instead of on every
push to main. Skips the build if no commits landed on main in the last
24 hours. Kept disabled (if: false) until a windows-runner is
registered.
Closes#77
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
timeout-minutes doesn't start until a runner accepts the job, so the
job would queue indefinitely. Disable with if: false for now — change
back to github.ref == 'refs/heads/main' once a windows-runner runner
is set up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
60-minute wait blocks every run. 5 minutes lets it fail fast with
continue-on-error, leaving the rest of the workflow unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With set -Eeuo pipefail, a failing fvm flutter test exited the script
before _e2e_exit=$? could run, so the retry-on-new-display logic never
fired. Use the cmd || var=$? pattern to capture the exit code safely,
and add || true to the break guard so set -e doesn't trip on it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The windows-runner self-hosted runner doesn't exist yet, so the job
would block the run indefinitely. With continue-on-error + timeout it
fails gracefully once a runner is registered and picks up the job.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds build-windows-release and deploy-windows-to-server Taskfile tasks,
a build-windows CI job (requires a windows-runner self-hosted runner),
and extends updateInfoProvider to also cover Platform.isWindows.
latest.json is now extended with a 'windows' key; both deploy tasks
preserve the other platform's URL when updating the file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Build task embeds GIT_HASH via --dart-define; new deploy-linux-to-server task
packages a tar.gz and updates latest.json on the server. The account list screen
shows a MaterialBanner when a newer Linux build is available.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ORDER BY receivedAt DESC to the searchAddresses query so the first
unique occurrence of each address comes from the newest email. Contacts
from recent conversations float to the top of the suggestions list.
Add a unit test verifying the sort order.
Fixes#83
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_RawAutocompleteState.dispose() removes _updateOptionsViewVisibility
from the external FocusNode but forgets to remove _onFocusChange. When
the state is recreated with the same FocusNode both listeners accumulate,
and the second hide() call hits the _zOrderIndex != null assertion in
overlay.dart:1681. This is a Flutter framework bug, not a test deficiency.
Restore the filter with a comment pointing to the root cause so it can
be removed when we upgrade past the fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When focus leaves the To field while the address DB query is in flight,
the optionsBuilder Future completes AFTER RawAutocomplete has already
called hide() on the overlay. The completion triggers a second hide()
call, hitting the _zOrderIndex != null assertion in overlay.dart.
Fix: check focusNode.hasFocus after the await; return [] if focus left,
which prevents RawAutocomplete from calling show()/hide() on a closed
overlay.
Also fixes#81 partially: after undo(), push an inverse UndoAction so
the undo log retains a record and the user can re-apply the operation.
Fixes#79
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /builds/ page returned 404 because website/content/builds/ was fully
gitignored — Hugo had no content to generate the section landing page.
Fix:
- Narrow .gitignore to only ignore year-subdirectories (YYYY/) so that
_index.md can be committed as a static fallback.
- Add website/content/builds/_index.md with section description.
- Enhance generate_build_history.py to fetch and display commit datetime
alongside title, and render _index.md as a flat list of all builds
(newest-day first) so the section landing page is useful immediately.
Fixes#82
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A plain pump() between enterText(To) and enterText(Subject) does not
prevent the _zOrderIndex assertion: hide() is called twice synchronously
during the focus-dispatch triggered by the second enterText().
Fix: explicitly call primaryFocus?.unfocus() after the To field, then
pump(300ms) so RawAutocomplete's OverlayPortal closes via a single
FocusNode notification. By the time Subject takes focus the overlay is
already hidden — no second hide() fires.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RawAutocomplete's OverlayPortalController.hide() was called twice:
once when focus left the To field and again when ComposeScreen was popped,
triggering the _zOrderIndex assertion in overlay.dart.
Fix by:
1. pump() after entering the To field so the overlay has a frame to close
before the Subject field takes focus.
2. unfocus() + pump() before tapping Send so the overlay is already hidden
when the screen pops, preventing a second hide() on unmount.
Remove the _zOrderIndex string-filter from FlutterError.onError — the
root cause is fixed rather than suppressed.
Fixes#79
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After showDialog and after the two repo awaits (getEmail/deleteEmail),
the widget may have been disposed — calling ref.read on a disposed
ConsumerStatefulElement throws "Cannot use 'ref' after the widget was
disposed." Add if (!mounted) return; at both points.
Fixes#80
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>