The searchEmails implementation now queries local SQLite FTS5 (not IMAP),
so syncEmails must be called first to populate the index. The first test
was already fixed; this adds the same syncEmails call to the second test
and adds a clarifying comment to the implementation.
Closes#506
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A WorkManager background task may have the database open when the
foreground app starts. Executing PRAGMA journal_mode = WAL on the
second connection then fails with SQLITE_BUSY_SNAPSHOT (extended code
261, primary code 5), crashing the app before it renders.
Two changes:
1. Move PRAGMA busy_timeout = 5000 before the WAL pragma so SQLite
auto-retries plain SQLITE_BUSY (code 5) for up to 5 s.
2. Extract setup logic into _setupPragmas and catch SqliteException
with resultCode == 5 (covers both SQLITE_BUSY and SQLITE_BUSY_SNAPSHOT).
SQLITE_BUSY_SNAPSHOT only occurs when the DB is already in WAL mode,
so the pragma is a no-op and it is safe to continue.
Adds a regression test that opens a second connection while a read
transaction holds a WAL snapshot open and verifies setupPragmasForTesting
does not throw.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#501
searchEmails now queries the local email_fts virtual table filtered by
mailbox_path instead of doing a live IMAP SEARCH. This makes folder-view
search work offline and ensures tapped results always open the correct
email (IDs come from the same local DB that getEmail reads from).
Reuses the existing FTS5 infrastructure (_toFtsQuery + the email_fts
content-table join) from searchEmailsGlobal, adding only the
`AND e.mailbox_path = ?` filter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- Tapping a row in the Undo Log list opens a new `UndoLogDetailScreen`
- Detail screen shows: account ID, action type (with icon/colour), timestamp, source folder, destination folder (move only), and a list of all emails in the transaction (subject + sender)
- Navigation uses go_router nested route `/accounts/undo-log/:actionId` with `state.extra` to pass the `UndoAction` object
- AppBar has an **Undo** button that calls the existing undo service and pops back
## Also fixed
- `flake.nix`: replaced the broken dagger/nix 0.20.8 Nix wrapper (infinite self-exec loop) with a direct 0.21.4 `fetchurl` derivation; wired `DAGGER_HOST` so the pre-commit `dart-check` hook can reach the running engine
- `pubspec.lock`: bumped `meta` 1.17→1.18 and `test` 1.30→1.31 to match what the CI resolver picks up (eliminates spurious generated-files drift in CI)
## Verification
- `task test` — all 492 unit/widget tests pass
- `dart analyze --fatal-infos` — clean (no warnings or infos)
- Pre-commit hooks (including `dart-check` via Dagger) — all passed on commit
Closes#450
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/461
When zone errors bubble up through Dart's async machinery the stack
trace is in package:stack_trace chain format (with '===== asynchronous
gap =====' separators). Flutter's StackFrame parser asserts on those
lines. FlutterError.demangleStackTrace strips the chain format back to
a plain VM trace before Flutter tries to parse it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
runZonedGuarded's error handler runs in the parent zone, so calling
runApp there caused a Flutter zone mismatch with ensureInitialized.
Removed the async keyword from main (redundant with runZonedGuarded)
and replaced the zone error handler's runApp call with reportError.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
Fixes the crash reported in #397: `Scaffold.of() called with a context that does not contain a Scaffold.`
- `Scaffold.of(context)` was called in the `onPressed` of the bottom-nav menu `IconButton` using the widget's own `build` context. That context is the *parent* of the `Scaffold` being returned, so Flutter correctly throws.
- Fix: wrap the `IconButton` in a `Builder`, which provides a child `ctx` that is a proper descendant of the `Scaffold`. `Scaffold.of(ctx)` then resolves correctly.
## Test plan
- [ ] Run app with bottom menu position enabled, tap the hamburger icon — drawer opens without crashing.
- [ ] Run app with top menu position — no regression (bottom nav is not rendered).
Closes#397🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/403
## Summary
- Removes the `AlertDialog` popup that appeared when tapping delete in thread view
- Deletion now happens immediately, matching the behaviour of the single mail view
- The existing `UndoShell` widget already listens for new `UndoAction` pushes and shows a snack bar with an **Undo** button — no extra UI code needed
Closes#398
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/402
## Summary
- When deleting a mail from the single Mail View, \`pushAction()\` was called with \`unawaited\` before \`_navigateTo()\`. This meant the UndoShell snack bar fired *after* navigation had already started, showing the snack bar on the destination scaffold mid-transition — which prevented the snack bar's duration timer from starting correctly.
- Fixed by changing \`unawaited(pushAction(...))\` to \`await pushAction(...)\`. Since Riverpod fires \`ref.listen\` synchronously when state changes, the UndoShell now queues the snack bar on the current stable scaffold *before* \`_navigateTo()\` is called. The snack bar then naturally transfers to the destination scaffold and auto-dismisses after 5 seconds as intended.
Closes#399
## Test plan
- [x] All 338 unit/widget tests pass
- [ ] Manually delete a mail from single Mail View and verify the snack bar appears and auto-dismisses after ~5 seconds
- [ ] Verify the Undo button in the snack bar still works
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/401
Closes#373
## Summary
- **Schema v38**: two new columns on `user_preferences` — `prefetch_mode` (default `wifiOnly`) and `body_cache_limit_mb` (default 100 MB).
- **`BodyCacheService`**: queries for emails that have no cached body, fetches them newest-first in batches of 20, and evicts the oldest cached bodies when the configured size limit is exceeded.
- **Separate WorkManager task** (`si_bg_prefetch`): runs hourly with `NetworkType.unmetered` (Wi-Fi) or `NetworkType.connected` (any) depending on the user's choice. The task is cancelled when prefetch is disabled.
- **App startup**: reads the stored preference from the DB and re-registers the WorkManager task with the correct constraint.
- **Preferences screen**: radio group for prefetch mode (Wi-Fi only / Any network / Disabled) and a dropdown for cache size limit (50 / 100 / 200 / 500 MB).
## What is NOT downloaded
Binary attachments are never fetched — `getEmailBody()` stores only `textBody` and `htmlBody`. The cache size limit + per-run batch cap (20 emails) keep storage bounded even on large mailboxes.
## Test plan
- [x] `task analyze` — no issues
- [x] `task test` — all 492 tests pass (incl. updated migration_test.dart for v38)
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/400
## Summary
- When viewing an email and then deleting (or archiving/moving/snoozing) it, the app navigates to the next email in the thread list.
- `getEmailBody` fetches from the network on a cache miss, causing the hourglass / loading spinner the issue describes.
- `EmailDetailNotifier` now fires a background `getEmailBody` call for the next thread's `latestEmailId` as soon as the current email finishes loading.
- `getEmailBody` already caches results in the `EmailBodies` table with a 7-day TTL, so by the time the user triggers a navigation action the body is pre-warmed and renders instantly.
## What changed
`lib/di.dart` — `EmailDetailNotifier.build()` calls `_prefetchNextEmailBody` (fire-and-forget via `unawaited`) after loading the current email. The helper respects the `afterMailViewAction` user preference: if set to `showMailbox` it does nothing.
## Test plan
- [ ] Open an email, delete it — next email should appear without the spinner
- [ ] Verify the same for archive, move, and snooze actions
- [ ] Verify behaviour is unchanged when `afterMailViewAction` is set to `showMailbox`
- [ ] Verify the last email in the list still pops back to the mailbox list correctly
Closes#367
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/381
## Summary
Closes#377
- Adds a new `ImageTrustedSenders` Drift table (schema v37) that stores email addresses for which remote images are loaded automatically (per device, not per account)
- When the user taps "Load remote images", the sender's address is saved and a 3-second snackbar appears with a "Settings" hyperlink to undo the choice in preferences
- Both `EmailDetailScreen` and `ThreadDetailScreen` check the trusted senders list on open and auto-load images for known senders
- The Preferences screen gains a new "Trusted image senders" section listing all saved senders with individual remove buttons
## Test plan
- [x] `dart run build_runner build` regenerates `database.g.dart` cleanly (schema v37)
- [x] `flutter analyze` — no issues
- [x] Migration test updated: checks `image_trusted_senders` table exists after upgrade and fresh install
- [x] `FakeUserPreferencesRepository` updated with three new interface methods
- [x] All 490 unit + widget tests pass (1 pre-existing golden test failure unrelated to this change)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/378
## What changed
`searchAddresses` (used by the To/Cc/Bcc autocomplete) now runs two passes over the candidate email rows:
1. **Sent-folder rows first** — the mailboxes table is queried for mailboxes with `role='sent'`; any email row whose `mailboxPath` matches gets processed before inbox/other rows. Within this group addresses are ordered by `receivedAt` DESC as before.
2. **All other rows** — processed after sent rows, also by `receivedAt` DESC.
Within sent-folder rows, `toAddresses` and `ccJson` are checked before `fromJson` (the sender in a sent email is our own address, not a useful suggestion). For non-sent rows the original order (`fromJson`, `toAddresses`, `ccJson`) is kept.
This means: if you wrote to `info@foo.de` yesterday and received spam from `info@spam.de` today, typing "i" surfaces `info@foo.de` first.
## How verified
- All 492 unit tests pass (`task test`).
- Added a dedicated test `searchAddresses prioritises sent-folder addresses over newer received` that inserts an older sent email and a newer received email matching the same query prefix and asserts the sent-folder address is returned first.
Closes#375
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/380
Closes#372
## What changed
- **New widget** `lib/ui/widgets/email_headers_dialog.dart`: full-screen header browser that organises headers into collapsible groups:
- **Headers** — all standard headers (expanded by default)
- **List- Headers** — all `List-*` headers grouped together (expanded)
- **Received** — all `Received` headers, **collapsed by default**; shows the inter-hop duration between consecutive entries and highlights delays in colour (green < 30 s, orange < 5 min, red >= 5 min)
- **ARC- Headers** — all `ARC-*` headers (above X-, expanded)
- **X-Prefix Headers** — X- headers split by their second component (e.g. `X-Google-*` → "X-Google Headers"), sorted alphabetically, at the very bottom
- **`email_detail_screen.dart`**: `_showHeaders` now uses `EmailHeadersDialog`; `_showStructure` converted from `AlertDialog` to `Dialog.fullscreen()` — satisfying "Make popup windows full screen."
- **`scripts/check_coverage.dart`**: new widget file added to the `_excluded` set (UI widgets are covered by integration tests, not unit tests).
## Verified
`task check` passes (analyze: no issues, 491 unit tests pass, coverage >= 80 %).
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/374
During _load(), check whether a password exists in secure storage and track the result
in _hasStoredPassword. The password field validator now requires user input when no
password is stored, so _tryConnection() fails fast at form validation instead of
throwing an unhandled StateError.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>