## 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
- Wraps the \`dagger call\` in \`deploy-linux\`, \`publish-android\`, and \`deploy-apk\` Taskfile tasks with \`scripts/silent_on_success.sh\`
- On success: no Dagger output is printed (eliminates the verbose logs seen in deploy.yml CI runs)
- On failure: full Dagger output is replayed so errors remain visible
The project already uses \`scripts/silent_on_success.sh\` for other noisy commands (fvm, flutter pub get, build_runner, etc.) — this applies the same pattern to the three deploy tasks called from \`.forgejo/workflows/deploy.yml\`.
Closes#389
## Test plan
- [ ] Verify deploy CI run produces significantly less output on success
- [ ] Verify that on a failure, the full Dagger output is still printed
🤖 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/390
## 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
## What
PR #356 (Renovate) was blocked with `renovate/artifacts` — \"Artifact file update failure\" — because `ci/go.sum` could not be updated automatically.
**Root cause**: `ci/main.go` imports `dagger/ci/internal/dagger` (generated by `dagger develop`, not committed to the repo). Without that generated package present, `go mod tidy` cannot resolve the full dependency graph, so Renovate's artifact update step always fails.
The actual OpenTelemetry version bump from PR #356 was already applied manually in PR #363.
## Fix
Adds a `packageRule` to `renovate.json` to disable the `gomod` manager for `ci/**`. Renovate will no longer open failing PRs for Go dependencies in the Dagger CI module; updates to `ci/go.mod` and `ci/go.sum` must be done manually (using `dagger develop && go mod tidy` inside `ci/`).
## Verification
- `renovate.json` validates against the Renovate schema.
- No Go or Drift schema changes; `task check` is unaffected.
Closes#368
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Co-authored-by: guettli <guettli@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/370
## Summary
Fixes three distinct failures from CI deploy run #1424 and concurrent website update failures.
- **Play Store job**: `pip install google-auth requests` fails on Ubuntu 24.04 with PEP 668. Fixed by using `python3 -m venv` for an isolated install.
- **SSH key error (APK, Linux, website jobs)**: All SSH/rsync steps fail with `Load key "/root/.ssh/id_ed25519": error in libcrypto` inside the Dagger Alpine 3.21 container. This is the first time these jobs actually ran (all previous deploy runs had every job skipped). Two fixes:
- `setup_dagger_remote.sh`: `export_secret` was appending an extra trailing newline to values (like SSH private keys) that already end with `\n`. Now only adds one when needed.
- `ci/main.go` `Deployer`: mounts the key at a `.raw` path, strips Windows-style CRLF endings with `tr -d '\r'`, then writes the normalised key to `id_ed25519`. CRLF bytes cause "error in libcrypto" in Alpine's LibreSSL-backed openssh.
## Test plan
- [ ] Deploy run triggers after merge; all three deploy jobs complete
- [ ] Play Store verification step passes
- [ ] SSH commands in Alpine load the key without `error in libcrypto`
Closes#366
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/369
Closes#361
Three bugs in the hourly deploy workflow's change-detection logic caused the Play Store to silently fall behind whenever a deploy failed or all-android jobs were skipped.
**Bug 1 (primary): commit_sha → head_sha**
Forgejo's API returns head_sha; commit_sha was always None. This meant LAST_DEPLOYED_SHA was always empty, so the diff fell back to HEAD~1..HEAD — only the single most recent commit was inspected. If android changes landed in an earlier commit, they were silently missed.
**Bug 2: Skipped runs counted as 'deployed'**
A workflow run where deploy-playstore was skipped (android=false) has status=success, so it was treated as a successful deploy. Now the code queries each run's job results and only trusts a run where the 'Build & Deploy to Play Store' job's own conclusion=success.
**Bug 3: Narrow fallback when SHA unknown**
When LAST_DEPLOYED_SHA could not be determined the workflow diffed HEAD~1..HEAD — potentially missing many commits. Now it defaults to android=true / linux=true (deploy everything) as the safe fallback.
Additional changes:
- ::error:: / ::warning:: / ::notice:: annotations so skip/failure reasons surface in the Actions UI.
- scripts/verify_playstore_deploy.py: new post-deploy check that queries the internal track and fails if the latest version code is more than 1 hour old. (Version codes are Unix timestamps set by ci/main.go's PublishAndroid.) Catches silent deploy failures the upload API did not reject.
- scripts/test_verify_playstore_deploy.py: 5 unit tests for the verify script (all pass).
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/364
Renovate only tracked the engine version in `ci/dagger.json`. This PR adds regex `customManagers` so Renovate also updates:
- `DAGGER_VERSION` in `.forgejo/Dockerfile`
- the nix flake reference (`github:dagger/nix/vX.Y.Z#dagger`) in `DAGGER.md`
All three now point to the same `dagger/dagger` GitHub releases datasource so they stay in sync via a single grouped PR.
Also bumps the stale `DAGGER.md` nix reference from `v0.11.4` to `v0.20.8` to match the current engine version.
Closes#358
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/365
## What
PR #356 (Renovate) was blocked with "Artifact file update failure" because `ci/go.sum` was out of sync with `ci/go.mod`.
**Root cause**: The `require` section listed otel log packages at v0.17.0 while `replace` directives pinned them to v0.19.0, but `go.sum` only had hashes for v0.16.0. Renovate couldn't auto-update go.sum because the Dagger module's `internal/dagger` generated package isn't in version control, so standard `go mod tidy` couldn't resolve the full dependency graph.
## Changes
- Bumps `go.opentelemetry.io/otel` + `otel/trace` + `otel/sdk` v1.43.0 → v1.44.0 (implementing PR #356's intent)
- Updates all related otel exporters and sub-packages to v1.44.0 / v0.20.0
- Aligns `replace` directives from v0.19.0 → v0.20.0 (consistent with require section)
- Also picks up `grpc` v1.79.3→v1.80.0 and `proto/otlp` v1.9.0→v1.10.0 (from `go mod tidy`)
- Adds all missing `h1:` and `/go.mod` hashes to `go.sum`
## Verification
- `go mod verify` passes
- Hashes fetched directly via `go mod download -json` from the official Go module proxy
Closes#359
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/363
## Root cause
`BuildWebsite` and `PublishWebsite` in `ci/main.go` ran `hugo --minify` without setting the `HUGO_PARAMS_GITVERSION` environment variable. Hugo maps that env var to `site.Params.gitversion`, which the `website/layouts/_partials/extend_head.html` template uses to render `<meta name="x-version" content="...">` in the page `<head>`.
Without that meta tag, `website-verify.sh` (which greps for `x-version.*${VERSION}` in the live HTML) always timed out and reported failure — even though the site itself was deployed successfully.
## Fix
- Added an optional `commitHash` parameter to `BuildWebsite` and `PublishWebsite` in `ci/main.go`. When provided, it is passed to the Hugo container via `WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)` — consistent with how `BuildLinuxRelease` and friends already inject `GIT_HASH`.
- Updated `task publish-website` in `Taskfile.yml` to compute `HASH=$(git rev-parse --short HEAD)` and forward it as `--commit-hash "$HASH"` — matching the pattern used by `task deploy-linux`.
## Verification
- `gofmt` passes on the modified `ci/main.go`.
- The logic mirrors the existing `BuildLinuxRelease` pattern that already works in CI.
Closes#360
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/362
## Summary
- The \`Detect Changed Files\` step in \`deploy.yml\` previously set \`android=false\` / \`linux=false\` silently, leaving downstream jobs showing only "skipped" in CI with no visible cause
- Now each decision emits a clear one-liner in the step log:
- \`Android deploy: SKIPPED (no android-relevant files changed)\`
- \`Android deploy: TRIGGERED (android-relevant files changed)\`
- \`Linux deploy: SKIPPED (no linux-relevant files changed)\`
- or \`HEAD <sha> already successfully deployed — skipping all deploy jobs\`
- The skip reason is visible in the \`check-changes\` job output, which is the job that makes the decision
Closes#353
## Test plan
- [ ] Trigger the deploy workflow on a commit that only touches CI/docs files — \`check-changes\` step log should show "Android deploy: SKIPPED (no android-relevant files changed)"
- [ ] Trigger the deploy workflow on a commit touching \`lib/\` — log should show "Android deploy: TRIGGERED"
- [ ] Trigger a second run on the same commit — log should show "already successfully deployed — skipping all deploy jobs"
🤖 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/357
## Summary
Closes#349
Two bugs prevented `check-dagger` from failing fast when checks failed:
- **Hygiene + Layers checked sequentially** — they are cheap structural checks with no dependency on each other. Running them in parallel (`errgroup.Group`) means failures are reported sooner.
- **Spurious retries from `errgroup.WithContext`** — the backend and integration tests previously shared a derived context via `errgroup.WithContext`. When one test failed, the context was cancelled, causing the sibling test to emit `"context canceled"` in Dagger's `--progress=plain` output. The `retry_dagger` function in `Taskfile.yml` matched that string as a transient network error and re-ran the entire pipeline up to 3 times — a real test failure could take 30+ minutes to be reported instead of ~10.
**Fix in `ci/main.go`:**
- Hygiene + layers now run in parallel with `errgroup.Group`
- Backend + integration tests now use `errgroup.Group` (no shared cancel context), so a failure in one does not emit `"context canceled"` for the other
**Fix in `Taskfile.yml`:**
- Removed `context canceled` from the `retry_dagger` grep pattern; the remaining patterns (`connection reset`, `context deadline exceeded`, `connection refused`, `invalid return status code`) still cover genuine network/engine transients
## Test plan
- [ ] Confirm the Forgejo CI run completes and, when a check fails, it fails fast (no 3× retry loop in logs)
- [ ] Verify `task check-dagger` still retries on actual connection errors
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Co-authored-by: guettli <guettli@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/350
## Summary
- `android/app/build.gradle.kts` used `kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }`, which Kotlin 2.x treats as a compilation error ("Using jvmTarget: String is an error")
- Replaced with the `compilerOptions` DSL using `org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17`
## Test plan
- [x] Confirmed root cause from CI run #1316 logs: `e: .../build.gradle.kts:20:9: Using 'jvmTarget: String' is an error`
- [ ] CI deploy workflow should now pass the Android bundle build step
Closes#351🤖 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/352
## Summary
- Disables the `github-actions` Renovate manager in `renovate.json`
- Removes the previous `fileMatch` override that pointed Renovate at Forgejo workflow files
- Stops Renovate from scanning workflow YAML files for action version updates, eliminating GitHub API calls and the "GitHub token is required" warning
## Test plan
- [ ] Verify `renovate.json` is valid JSON (done locally with `python3 -m json.tool`)
- [ ] Confirm the next Renovate run no longer produces the GitHub token warning in its logs
🤖 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/306
## Summary
- The hourly `deploy.yml` schedule re-deployed the same commit repeatedly because it always diffed `HEAD~1..HEAD` — once a commit touching `lib/`/`pubspec.*` became HEAD, every hourly tick would detect "android changes" and deploy again.
- Fix: at the start of the `check-changes` job, query the Forgejo workflow runs API for the last successful `deploy.yml` run. If its `head_sha` matches current HEAD, output `android=false` / `linux=false` immediately, skipping all downstream jobs.
- `workflow_dispatch` bypasses this check (always deploys), matching the existing behaviour.
## Test plan
- [ ] Verify the `check-changes` job exits early on the next scheduled run after a successful deploy of the same commit
- [ ] Verify a new commit still triggers deployment normally
- [ ] Verify `workflow_dispatch` still deploys unconditionally
🤖 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/265
When a catch-up PR merge fails (PR stays open after the merge command), the loop sets the issue to State/Question and comments on it. But on the next cron tick the same PR is still open with passing CI, so it tries again — spamming the issue with identical comments every minute.
Fix: before attempting a catch-up merge, fetch the issue's current labels via `_get_issue_labels()`. If `State/Question` is already set, skip the PR entirely.
Closes#239
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/242
## Summary
- Increases the retry delays in `_resolveDatabasePath()` from `[100, 300, 600]` ms (~1 s) to `[200, 500, 1000, 2000]` ms (~3.7 s).
- Adds a regression test (`test/unit/database_path_test.dart`) that verifies `initDatabasePath()` does not throw when the `path_provider` channel is unavailable.
## Root cause
On some slow Android devices (e.g. the Motorola reported in #166), the `path_provider` Pigeon channel is not ready even several seconds after `runApp()` returns. The previous back-off budget of ~1 s was not enough, causing `_resolveDatabasePath()` to exhaust all retries and throw a `PlatformException`, crashing the app with the message shown in the issue.
## Test plan
- [ ] `flutter test test/unit/database_path_test.dart` passes (new regression test)
- [ ] `flutter test test/unit/` — all 325 unit tests pass
- [ ] `flutter analyze` — no issues
Fixes#166
Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/169