- 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>
SSH secrets (SSH_USER, SSH_HOST, SSH_PRIVATE_KEY) are not yet configured
as repository secrets. Mark the four SSH-dependent steps continue-on-error
so the Play Store deploy job succeeds while those secrets are pending.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The deploy-apk-to-server task depends on build-android which signs the
APK — it needs the keystore password or the packageRelease Gradle task
fails.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_verifyErrorWidgetBuilderUnset is called from _runTestBody after testBody()
returns, but addTearDown callbacks run after _runTestBody — so teardown is
too late for this check. Restore ErrorWidget.builder inline, right after
app.main() sets it, so the binding sees the original value when it verifies.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
app.main() also sets ErrorWidget.builder to its CrashScreen handler.
The test binding's _verifyErrorWidgetBuilderUnset check fires when
ErrorWidget.builder != its pre-test value after the test completes.
Save and restore ErrorWidget.builder alongside FlutterError.onError.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OverlayPortalController.hide() asserts _zOrderIndex != null before
clearing it. In headless tests without navigation animations, rapid
screen dismissal can trigger hide() twice (once on focus loss, once on
widget unmount) — a Flutter framework race that overlay.dart itself
notes should not happen during rebuilds. Filter it alongside the
existing DEFUNCT/DISPOSED suppressions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
app.main() synchronously sets FlutterError.onError to its crash-screen
handler, overwriting the filter the test had registered first. The test
binding's _runTest finally-block checks FlutterError.onError != _recordError
and fires assertion '_pendingExceptionDetails != null', which prevents the
integration test framework from calling exit() — causing the process to hang
for the full 360-second timeout.
Fix: capture the binding's error recorder (bindingError) before app.main(),
call app.main() first, then install the DEFUNCT/DISPOSED filter pointing at
bindingError, and restore to bindingError in teardown. This keeps the crash
handler from interfering with the test binding's error tracking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Future.any([Future.delayed(N), stopSignal.future]) left unfired Timers
alive after stop() fired the signal — pending Timers kept the Dart event
loop running and prevented the process from exiting, causing the E2E
integration test to time out (exit 124) instead of exiting cleanly.
Replace all four occurrences with an explicit Timer that completes the
stop-signal and is cancelled in a finally block, so the Dart isolate can
exit as soon as the sync loops are stopped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- scripts/generate_build_history.py: SSH into server, list APKs under
public_html/builds/YYYY/MM/DD/, fetch commit titles from Codeberg API,
and write Hugo content pages to website/content/builds/
- Taskfile: add deploy-apk-to-server and generate-build-history tasks;
add --exclude='*.apk' to website-deploy rsync so APKs survive redeploy
- CI: after Play Store deploy, set up SSH key, scp APK, generate history,
then deploy website
- .gitignore: exclude website/content/builds/ (generated at deploy time)
- website/hugo.toml: add Builds nav item
Closes#73
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a sync failure banner appears in the email list screen, a new
'View log' button navigates directly to the account's sync log screen
so the user can see the full error details.
Also creates issue #75 for the first-snooze-in-new-account failure.
Closes#13
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds RawAutocomplete<EmailAddress> to the To and Cc fields in the
compose screen. As the user types (minimum 2 chars), suggestions are
fetched from the local DB by searching from/to/cc columns of cached
emails. Selecting a suggestion appends it to any existing addresses
already in the field (comma-separated).
New repository method searchAddresses() returns deduplicated
EmailAddress objects matching the query string.
Closes#11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue reports now include:
- App version (from package_info_plus)
- OS name and version (non-personal, from dart:io Platform)
- Error and stack trace wrapped in triple-backtick code blocks
so Codeberg renders them as preformatted text
Closes#59
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
actions/checkout defaults to fetch-depth: 1 (shallow clone).
generate-changelog runs git log -n 50, so only one entry appeared
in the built app. Fetching 50 commits gives a complete changelog.
Closes#64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The user should set State/Ready manually when the issue is ready
to be worked on, not automatically on creation.
Closes#74
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add --no-warn-dirty to all nix develop calls to suppress Git dirty-tree warnings
- Switch integration test reporter from expanded to compact (per-test names suppressed on success)
- Show only summary line on integration test success, matching unit/widget test behavior
Closes#8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getSingle() throws 'Bad state: No element' when the email row is gone
(race condition in batch operations or already deleted). Switch to
getSingleOrNull() and return early so batch moves/flags/deletes on
stale IDs fail silently instead of crashing.
Closes#58
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>