Compare commits

...
Author SHA1 Message Date
agentloop 39fdac3476 plan: refresh plan for issue #539 2026-06-08 06:19:17 +00:00
Bot of Thomas Güttler 13a0c99f57 test(search): cover sort order of searchEmailsStructured and getEmailsByAddress (#534) 2026-06-07 20:24:25 +02:00
Bot of Thomas Güttlerandguettli 41c8196a97 feat(detail): drop AppBar subject, surface Mark as spam icon (#531)
## Summary
- Drop the truncated subject preview from the single-mail AppBar title; the full subject is already shown in the body header.
- Replace the popup-menu entry for **Mark as spam** with a direct `IconButton` (`Icons.report_outlined`) in the AppBar actions so the action is reachable without opening the `⋯` menu.
- Update affected widget tests for the new layout (subject is only in the body header; spam action is now a standalone button rather than a popup item).

Closes #528

## Test plan
- [x] `dart format --output=none --set-exit-if-changed lib test` — 0 changed
- [x] `dart analyze --fatal-infos lib test` — no issues
- [x] `flutter test test/widget/email_detail_screen_test.dart test/widget/email_list_screen_test.dart` — 42/42 passing
- [x] Full widget suite (`flutter test test/widget/`) — 172/172 passing

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/531
2026-06-07 20:05:57 +02:00
Bot of Thomas Güttler 38f7ada8b5 chore(deps): bump go_router, file_picker, flutter_local_notifications (#532) 2026-06-07 19:45:22 +02:00
Bot of Thomas Güttler a227f8607c fix(ci): use endpoints that exist in Forgejo for wait-time + LAST_DEPLOYED_SHA (#529) 2026-06-07 14:02:01 +02:00
Bot of Thomas Güttler 5db5d957ab fix(ci): use /actions/runs endpoint in remaining wait-time steps (#524) 2026-06-07 06:59:00 +02:00
Bot of Thomas Güttler 0dd1d7232b fix(ci): use /actions/runs endpoint in deploy.yml wait-time steps (#522) 2026-06-07 06:33:57 +02:00
Bot of Thomas Güttler 282a64b4c3 fix: include mailboxPath in IMAP email ID to prevent UID collisions (#511) 2026-06-07 05:30:59 +02:00
Bot of Thomas Güttler 8e26715658 ci: eliminate duplicate build_runner run in CheckGenerated (#514) 2026-06-07 05:30:43 +02:00
Bot of Thomas Güttler e4cc92867e ci(website): add change detection to skip unconditional hourly deploys (#515) 2026-06-07 05:04:58 +02:00
Bot of Thomas Güttler 609208247a ci: parallelize Format/Analyze/CheckGenerated/Coverage in Check() (#513) 2026-06-07 04:38:35 +02:00
Bot of Thomas Güttler 69606ce586 fix: prevent Enter key from re-running a settled search (#479) 2026-06-07 04:38:30 +02:00
Bot of Thomas Güttler 9081b452f3 feat: add structured search with visual filter builder (#469) 2026-06-07 04:38:28 +02:00
Bot of Thomas Güttler b9ccafc709 feat: allow manual entry of glob patterns for trusted image senders (#480) 2026-06-07 04:38:22 +02:00
Bot of Thomas Güttler b1e1ac1de7 fix: remove dual-stack [::]:PORT bind (silences spurious EADDRINUSE errors) (#481) 2026-06-07 04:38:21 +02:00
Bot of Thomas Güttler f22f211e8a docs: update AGENTS.md for new agentloop defaults (merge prompt + label rename) (#471) 2026-06-07 04:38:19 +02:00
Bot of Thomas Güttler 76f2635700 fix(search): sort search results by received date descending (#520) 2026-06-07 04:24:24 +02:00
Bot of Thomas Güttler e2bb299300 fix(ci): exclude chaos_monkey_test from regular CI (#518) 2026-06-07 04:24:10 +02:00
Bot of Thomas Güttler f5abe9132b fix(test): sync before searching in second searchEmails IMAP test (#519) 2026-06-07 02:49:53 +02:00
Bot of Thomas Güttler d55b316d4c ci: add concurrency cancel-in-progress to ci.yml (#516) 2026-06-07 02:40:13 +02:00
Bot of Thomas Güttler f7fd30da15 feat(ci): add Print runner wait time step to all workflow jobs (#517) 2026-06-07 02:40:08 +02:00
Bot of Thomas Güttler d92cfac761 feat(search): include email notes in search results (#512) 2026-06-07 01:58:22 +02:00
57b266a82b fix(lint): move sqlite3 to dependencies, use close() instead of dispose()
- sqlite3 is now imported in lib/ (production code), so it must be a
  regular dependency, not a dev_dependency
- Replace deprecated conn.dispose() with conn.close() in the test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:32:13 +02:00
b7a8624c38 fix(ci): forward SSH tunnel directly to dagger engine socket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:32:13 +02:00
Thomas SharedInboxandBot of Thomas Güttler 1e2f124cd0 ci: re-trigger CI check 2026-06-07 00:32:13 +02:00
916fc4bc6b fix: swallow SQLITE_BUSY when setting WAL mode to prevent crash on startup (#508)
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>
2026-06-07 00:32:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a67b707a41 fix(test): sync before searching in searchEmails IMAP test
searchEmails now queries local SQLite FTS5 instead of IMAP directly
(since 65173d3). The test must call syncEmails first to populate the
local index before searching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:28:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 156ccae83b fix(ci): forward SSH tunnel directly to dagger engine socket
Eliminates the socat bridge dependency by using OpenSSH's built-in
Unix socket forwarding (-L port:socket_path). The dagger user already
owns /run/dagger/engine.sock so no intermediate TCP listener is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:15:19 +02:00
Thomas SharedInbox 9fd30d8f28 ci: re-trigger CI check 2026-06-06 22:34:16 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e22322166c feat: linkify #NNN references in ChangeLog to Codeberg issues
Closes #472

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:51:13 +02:00
913f9e8855 fix: prevent duplicate CI runs on pull request pushes (#490)
## Summary

- The CI workflow used `on: [push, pull_request]`, which fires **two** runs whenever a commit is pushed to a branch with an open PR — one for the `push` event and one for the `pull_request` event.
- Scoped the `push` trigger to `branches: [main]` only. Feature-branch pushes now trigger only via `pull_request`; direct pushes to `main` (merge commits) still trigger via `push`.

## Test plan

- [ ] Open a PR and push a new commit — verify only one CI run appears, not two
- [ ] Merge a PR to `main` — verify CI still runs via the `push` trigger

Closes #483

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/490
2026-06-06 21:43:46 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 65173d323c feat: switch folder-view search from IMAP to local SQLite FTS5
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>
2026-06-06 20:43:53 +02:00
72f634dd90 fix(tests): remove stale search-toggle test and fix ink_sparkle shader crash
The 'tapping search icon shows search bar' test was stale: the SearchBar is
now permanently visible in AppBar.bottom, so both its assertions held before
any tap. Deleted it; the existing 'SearchBar is always visible in the AppBar'
test already covers the same intent.

Added NoSplash.splashFactory to the widget-test ThemeData to prevent Flutter
from loading the pre-compiled ink_sparkle.frag shader, which was built for an
older SDK version and caused an INVALID_ARGUMENT crash on Flutter 3.44.0.

Closes #486

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 18:19:10 +02:00
Bot of Thomas Güttler 4712e768ea fix: prevent Enter key from re-running a settled search (#473) 2026-06-06 18:02:50 +02:00
Bot of Thomas Güttler 7985caa9b4 fix: discard stale search results when a newer query supersedes them (#468) 2026-06-06 10:32:37 +02:00
Bot of Thomas Güttler e28996cf86 feat: track installed versions and annotate ChangeLog with install dates (#457) 2026-06-06 10:31:06 +02:00
guettlibotandBot of Thomas Güttler d994723a2d chore(deps): update plugin com.android.application to v9 (#465) 2026-06-06 09:04:32 +02:00
Bot of Thomas Güttler 145346c18a refactor: build Android bundle locally via fvm instead of Dagger (#463) 2026-06-06 09:04:13 +02:00
guettlibotandBot of Thomas Güttler f3e1ca13de chore(deps): update dependency flutter_launcher_icons to ^0.14.0 (#464) 2026-06-06 09:01:21 +02:00
d86ce7766c feat: add undo log detail view (#461)
## 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
2026-06-06 05:43:17 +02:00
f88d14f362 fix: register SOPS-decrypted secrets for CI log redaction (#460)
## Summary

- The Forgejo/GitHub Actions runner only redacts values it has been explicitly told about. Secrets exported via `$GITHUB_ENV` in `setup_dagger_remote.sh` were never registered, so they could appear in plain text in CI log output.
- Added `::add-mask::` calls for every secret exported by `export_secret()`, and for the two inline variables `DAGGER_SSH_KEY` and `DAGGER_ENGINE_HOST` that bypass that function.
- Multiline values (e.g. SSH private keys, JSON key files) are masked line-by-line, since `::add-mask::` covers a single line at a time.

## Test plan

- [ ] Trigger a `workflow_dispatch` run of `deploy.yml` and confirm no secret values appear in plain text in the "Setup Dagger Remote Engine" step or any subsequent steps.
- [ ] Confirm the existing `[secrets] exported NAME (N chars)` log lines still appear (they log only the name and length, not the value).

Closes #434

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/460
2026-06-06 05:38:47 +02:00
3e2da2bdf8 feat: use icon.svg as app icon for Android and Linux (#459)
Closes #451

## What changed

Replaces the default Flutter blue logo with the project's rainbow-rings `icon.svg` on all supported platforms.

**Android** — all five mipmap densities regenerated (`mdpi` 48px through `xxxhdpi` 192px).

**Linux** — `linux/sharedinbox.png` (512×512) added, installed next to the binary via `CMakeLists.txt`, and set as the GTK window icon via `gtk_window_set_icon_from_file` in `my_application.cc`.

**Tooling** — `icon.png` (1024×1024 source raster) committed; `flutter_launcher_icons` added as dev dep with a `flutter_icons` config block; `task generate-icons` added to `Taskfile.yml` for future regeneration; `librsvg` added to `flake.nix` so `rsvg-convert` is available inside `nix develop`.

## How verified

Icons were generated with Inkscape from `icon.svg` and visually confirmed (rainbow-rings design appears correctly at all sizes). The `playstore/icon.png` was already correct and unchanged.

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/459
2026-06-06 05:32:29 +02:00
6a60c8d73b fix: resolve dart analyze failures in chaos_monkey_test.dart (#458)
## Summary

Fixes CI failures introduced by PR #455 (chaos monkey backend test).

The `dart analyze --fatal-infos` step in CI was failing because `test/backend/chaos_monkey_test.dart` had:

- **`avoid_print`** (5 instances): replaced `print(...)` with `stdout.writeln(...)` — `dart:io` is already imported
- **`avoid_redundant_argument_values`**: removed redundant `''` from `_env('CHAOS_SEED', '')` since `''` is the parameter default
- **`dart format`**: applied formatter fixes (trailing commas, line wrapping for long `connectToServer` calls)

## Verification

```
$ nix develop --command bash -c "fvm dart analyze --fatal-infos"
Analyzing 456...
No issues found!

$ nix develop --command bash -c "fvm dart format --output=none --set-exit-if-changed test/backend/chaos_monkey_test.dart"
Formatted 1 file (0 changed) in 0.01 seconds.
```

Closes #456

🤖 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/458
2026-06-06 05:29:40 +02:00
985bac7022 refactor: migrate deploy-android-bundle to Dagger (#449)
## Summary

- Deletes `scripts/build_android_bundle_local.sh`, which required a host Android SDK and failed with `No Android SDK found`
- Removes the `build-android-bundle-local` Taskfile task that invoked it
- Rewrites `deploy-android-bundle` to call the existing Dagger `publish-android` pipeline (build → stamp versionCode → sign → upload) via `sops exec-env` for local secret injection — no local Android SDK needed

The `publish-android` Dagger function (`ci/main.go`) already handles everything the old script did (keystore decode, AAB build, signing) plus version-code stamping, so no changes to `ci/main.go` are required.

Closes #444

🤖 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/449
2026-06-05 22:43:22 +02:00
aed0d63703 feat: track Flutter version in Renovate via Docker datasource (#452)
## Summary

- Adds a custom Renovate manager that reads the pinned Flutter version from `.fvmrc`
- Uses `ghcr.io/cirruslabs/flutter` as the Docker datasource so Renovate only proposes a bump when the corresponding image tag exists in the registry
- The CI pipeline (`ci/main.go`) already derives the Docker image tag from `.fvmrc` at runtime — `.fvmrc` is the single source of truth; no other files need grouping

## How it works

Renovate checks `ghcr.io/cirruslabs/flutter` for available tags. If `3.44.1` doesn't exist yet, no PR is opened. Once the image is published, Renovate opens a PR to bump `.fvmrc` — the only file that needs to change.

## Verification

- `renovate.json` schema validated
- Reviewed `ci/main.go`: `FlutterVersion` is read exclusively from `.fvmrc`; no hardcoded version strings elsewhere require additional grouping rules

Closes #447

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/452
2026-06-05 22:42:47 +02:00
Bot of Thomas Güttler 8446b05601 feat: add per-email notes stored on IMAP/JMAP server (#443) 2026-06-05 19:31:35 +02:00
Bot of Thomas Güttler bcece9f0af refactor: unify mail display with shared ThreadTile widget (#445) 2026-06-05 19:06:29 +02:00
Bot of Thomas Güttler 3bd404f0cf feat: add 'Create new folder' option to Move To Folder dialog (#423) 2026-06-05 18:53:36 +02:00
Bot of Thomas Güttler 9ca7089c50 fix: enforce non-root execution in Taskfile and shell scripts (#433) 2026-06-05 18:41:36 +02:00
Bot of Thomas Güttler adef2e9f80 feat: unify thread list views via shared EmailThreadTile widget (#431) 2026-06-05 18:11:28 +02:00
Bot of Thomas Güttler 2788a43dda feat: dedicated page for allowed image-sender addresses (#420) 2026-06-05 17:53:48 +02:00
71dac3cbb2 fix: remove hashed_ip from bugreport service, store email in mail.eml (#442)
## Summary

- **Remove hashed_ip entirely**: dropped `HashedIP` field, `hashIP` function, and all IP extraction logic from the server. No IP address is collected or stored in any form.
- **Move contact email out of report.json**: if the user opts to include their email for follow-up, it is now written to `mail.eml` in the report directory instead of being embedded in `report.json`. This keeps PII separate from the structured report data.
- Remove now-unused imports (`crypto/sha256`, `encoding/hex`, `strings`).
- Flutter client (`bug_report_screen.dart`) was already not sending a `hashed_ip` field — no client changes needed.

## Test plan

- [x] `go build ./...` in `server/bugreport/` passes with no errors
- [x] `go vet ./...` passes with no warnings
- Reports without a contact email produce only `report.json` (no `mail.eml`)
- Reports with a contact email produce `report.json` (no `email` key) and `mail.eml` containing the address

Closes #441

🤖 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/442
2026-06-05 16:22:59 +02:00
Thomas Güttler 913e5493f5 chore: move go.mod to repository root 2026-06-05 15:11:30 +02:00
Thomas Güttler 2612f4dbcd chore: rename go module path for remote go run 2026-06-05 14:50:17 +02:00
cca0e5d461 feat: run local Dart tasks via Dagger (#417) (#418)
## Summary

- Adds \`CheckFast\`, \`Analyze\`, \`FormatWrite\`, \`Codegen\`, and \`AnalyzeFix\` Dagger functions to \`ci/main.go\`
- Updates \`format\`, \`codegen\`, \`analyze\`, \`analyze-fix\`, and \`check-fast\` Taskfile tasks to delegate to Dagger and export modified files back to the host (\`-o .\`)
- Updates the \`dart-check\` pre-commit hook to run \`dagger call ... check-fast\` instead of \`scripts/pre_commit_check.sh\`

These tasks no longer require a local Dart/Flutter SDK installation — Dagger pulls the toolchain image and runs everything in a container.

## New Dagger functions

| Function | Returns | What it does |
|---|---|---|
| \`CheckFast(ctx)\` | \`string\` | Runs CheckHygiene, CheckLayers, Format, Analyze, CheckMocks, Coverage in parallel |
| \`Analyze(ctx)\` | \`string\` | \`dart analyze --fatal-infos\` |
| \`FormatWrite()\` | \`*dagger.Directory\` | \`dart format lib test\` — writes files, returns \`/src\` |
| \`Codegen()\` | \`*dagger.Directory\` | \`flutter pub run build_runner build\` — returns \`/src\` with generated files |
| \`AnalyzeFix()\` | \`*dagger.Directory\` | \`dart fix --apply\` — writes files, returns \`/src\` |

## Test plan

- [ ] \`task format\` — runs Dagger, exports formatted files back to \`.\`
- [ ] \`task codegen\` — runs Dagger, exports generated \`.g.dart\` files back to \`.\`
- [ ] \`task analyze\` — runs Dagger, prints analysis output
- [ ] \`task analyze-fix\` — runs Dagger, exports fixed files back to \`.\`
- [ ] \`task check-fast\` — runs all fast checks in parallel via Dagger
- [ ] Pre-commit hook triggers \`dagger call ... check-fast\` on commit

Closes #417

🤖 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/418
2026-06-05 11:50:49 +02:00
8718339b4e ci: add timeouts to all CI/CD jobs, Dagger tasks, and runner scripts (#432)
Closes #415

## Summary

- Adds missing `timeout-minutes` to `ci.yml` (`check` job, 60 min) and `windows-nightly.yml` (90 min, ready for when the Windows runner is registered)
- Wraps `ssh-keyscan` and `ssh -f -N -L` tunnel creation in `setup_dagger_remote.sh` with `timeout 30`; emits a `::warning::` annotation when either takes more than 10 s
- Adds `timeout --kill-after=10 <N>` to all bare `dagger call` invocations in `Taskfile.yml`: 600 s for test/query tasks, 1800 s for build/deploy tasks, 60 s for `ci-graph`; `stalwart` and `check-dagger` (already protected) left untouched
- Adds `timeout --kill-after=10 2400` per attempt in `run_firebase_test.sh`; emits `::warning::` on exit 124 instead of silently retrying

## Test plan

- CI passes on this PR (the `check` job now has `timeout-minutes: 60` and will self-enforce)
- All `dagger call` lines in `Taskfile.yml` now have a `timeout` prefix (visible in the diff)
- `setup_dagger_remote.sh` logic is unchanged — only the two network calls are wrapped

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/432
2026-06-05 11:49:30 +02:00
guettlibotandguettli ccefccf6a6 chore(deps): update gradle to v9 (#438)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [gradle](https://gradle.org) ([source](https://github.com/gradle/gradle)) | major | `8.14.5` → `9.5.1` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/276) for more information.

>  **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMTIuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIxMi40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/438
2026-06-05 11:49:11 +02:00
7a4defbab4 fix: make Android signing config conditional on ANDROID_KEYSTORE_PATH (#440)
## Summary

- `BuildAndroidRelease` in `ci/main.go` intentionally builds the AAB without setting up the keystore — the unsigned AAB is later stamped with `StampAndroidVersionCode` and re-signed by `SignAndroidBundle` via jarsigner.
- The old `signingConfigs.create("release")` block in `android/app/build.gradle.kts` called `error("ANDROID_KEYSTORE_PATH is not set")` at Gradle _configuration_ time, which fired even when the keystore wasn't needed for the build step.
- Fix: guard the `signingConfigs` block and the `signingConfig` assignment in the release build type behind a null-check on `ANDROID_KEYSTORE_PATH`. When the env var is absent (unsigned build path), Gradle skips the signing config entirely; when it is present (e.g. `BuildAndroidApk` via `setupKeystore`), the config is created and applied as before.

## Test plan

- Trigger `deploy.yml` via `workflow_dispatch` and verify the `Build & Deploy to Play Store` job no longer fails at step 4 with "ANDROID_KEYSTORE_PATH is not set"
- Verify `BuildAndroidApk` (which calls `setupKeystore`) still produces a correctly signed APK

Closes #439

🤖 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/440
2026-06-05 11:48:46 +02:00
Thomas Güttler 31c0479fc9 new name. 2026-06-05 10:11:56 +02:00
Thomas Güttler bde782f511 new pre-commit hook config 2026-06-05 10:09:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0cefc8f8e7 load android signing secrets from SOPS for local builds
Keystore is decoded into /dev/shm (tmpfs, RAM-only) during the build
and cleaned up on exit — never written to physical disk. ANDROID_KEYSTORE_PATH
is now required with no fallback; missing it fails loudly. Dagger CI path
updated to write to /tmp and set ANDROID_KEYSTORE_PATH accordingly.

Also fix check_ci_images.sh: filter out incomplete image tags ending in ':'
that arise from dynamic From("image:"+variable) concatenations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:00:26 +02:00
Thomas Güttler 3db1bd8ac2 remove old snooze plan. 2026-06-05 08:39:16 +02:00
Thomas Güttler 515b12dd0f clean up on main (remove plan.md) 2026-06-05 08:37:51 +02:00
Thomas Güttler 2ceabcacf0 clean up (on main) 2026-06-05 08:34:50 +02:00
Thomas Güttler a56eca0851 clean up in README 2026-06-05 08:21:13 +02:00
Thomas Güttler 85c9df604b ... 2026-06-05 08:19:48 +02:00
Thomas Güttler 68950e6888 Merge branch 'issue-421-bug-report' 2026-06-04 22:18:00 +02:00
Thomas Güttler 59a9ed9109 Implement bug report uploading backend and Flutter client UI (#421) 2026-06-04 22:14:04 +02:00
3d2288ab9f fix: downgrade Flutter to 3.44.0 — cirruslabs image for 3.44.1 not published (#428)
## Summary

- Downgrades `.fvmrc` from Flutter `3.44.1` back to `3.44.0` — `ghcr.io/cirruslabs/flutter:3.44.1` does not exist on GHCR so every Dagger-based deploy job fails with "not found"
- Extends `scripts/check_ci_images.sh` to also validate the Flutter image derived from `.fvmrc` (previously only literal `From("...")` calls in `ci/main.go` were checked, so Renovate bumps to non-existent images went undetected)
- Updates `.pre-commit-config.yaml` to trigger the `ci-image-exists` hook on `.fvmrc` changes as well as `ci/main.go`

## Root cause

Recent run logs showed:
```
! ghcr.io/cirruslabs/flutter:3.44.1: not found
Error: failed to resolve image "ghcr.io/cirruslabs/flutter:3.44.1"
```
Renovate bumped Flutter to 3.44.1 (#411) but cirruslabs has not published that image — the latest available is `3.44.0`. Same root cause as #409, but the pre-commit guard only watched `ci/main.go`, not `.fvmrc`.

Closes #427

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/428
2026-06-04 22:05:18 +02:00
4ef441ab1b ci: run non-golden widget tests in CI coverage (#416)
This PR includes widget tests (excluding golden tests) in the CI coverage run, ensuring widget layout and UI logic are tested automatically.

Co-authored-by: Thomas Güttler <thomas.guettler@syself.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/416
2026-06-04 19:34:53 +02:00
f28630fd7e fix: derive Flutter image tag from .fvmrc to prevent version mismatch (#405)
## What

`ci/main.go` previously hardcoded the Flutter container image tag (`ghcr.io/cirruslabs/flutter:3.44.0`) separately from `.fvmrc` (`{ "flutter": "3.44.1" }`). These two values drifted, causing the deploy failure in #394.

## How

`New()` now accepts `ctx context.Context` and returns `(*Ci, error)`. It reads `.fvmrc` from the source directory, parses the `flutter` field, and stores it as `Ci.FlutterVersion`. `toolchain()` constructs the image tag as `"ghcr.io/cirruslabs/flutter:" + m.FlutterVersion`. `Graph()` also uses the live value instead of a stale literal.

Result: `.fvmrc` is the single source of truth. Bumping Flutter via Renovate or manually only requires editing `.fvmrc`; the Dagger pipeline picks up the new version automatically.

## Verification

- `gofmt -e ci/main.go` passes
- No schema changes; no `build_runner` run needed

Closes #396

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/405
2026-06-04 17:35:08 +02:00
guettlibotandguettli 6177605f22 chore(deps): update dependency flutter to v3.44.1 (#411)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [flutter](https://flutter.dev) ([source](https://github.com/flutter/flutter)) | patch | `3.44.0` → `3.44.1` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/276) for more information.

>  **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMTEuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIxMS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhdXRvbWVyZ2UiLCJkZXBlbmRlbmNpZXMiXX0=-->

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/411
2026-06-04 17:34:53 +02:00
guettlibotandguettli ccfdfdb92e chore(deps): update plugin org.jetbrains.kotlin.android to v2.4.0 (#412)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| org.jetbrains.kotlin.android | `2.3.21` → `2.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.android:org.jetbrains.kotlin.android.gradle.plugin/2.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.android:org.jetbrains.kotlin.android.gradle.plugin/2.3.21/2.4.0?slim=true) |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/276) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMTEuMCIsInVwZGF0ZWRJblZlciI6IjQzLjIxMS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhdXRvbWVyZ2UiLCJkZXBlbmRlbmNpZXMiXX0=-->

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/412
2026-06-04 17:34:31 +02:00
b631bdae24 feat: validate ci/main.go container images in pre-commit (#413)
## Summary

- Adds `scripts/check_ci_images.sh`: extracts every `From("...")` image reference from `ci/main.go` and runs `skopeo inspect --no-creds` on each one (manifest-only, no layer pull, no daemon required)
- Adds `task check-ci-images` task in `Taskfile.yml` that runs the script
- Adds `ci-image-exists` hook to `.pre-commit-config.yaml` that fires only when `ci/main.go` is staged (using `files: ^ci/main\.go$` rather than `always_run`, to avoid a network round-trip on every unrelated commit)
- Adds `skopeo` to the Nix devShell so the tool is on PATH when the hook runs via `nix develop --command`

This catches a bad image tag (like `ghcr.io/cirruslabs/flutter:3.44.1` not yet published) at commit time, before the push reaches CI.

## Test plan

- Stage a change to `ci/main.go` bumping a `From("...")` tag to a non-existent version → hook rejects commit with NOT FOUND
- Stage a change with valid image tags → hook prints OK for each image and allows the commit
- Stage a change to any other file → `ci-image-exists` hook is skipped entirely

Closes #407

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/413
2026-06-04 17:34:17 +02:00
Thomas SharedInbox 4a07a175b9 remove debug banner on screenshots. 2026-06-04 16:42:42 +02:00
Thomas SharedInbox 2137d25d6d screen resolution. 2026-06-04 16:36:57 +02:00
Thomas SharedInbox d03ee8b555 fix missing fonts. 2026-06-04 15:04:19 +02:00
Thomas SharedInbox a82927cae8 create screenshots. 2026-06-04 14:53:50 +02:00
Thomas Güttler 6b1627b4c9 playstore icons 2026-06-04 14:26:10 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ef3255cd2b fix: set demangleStackTrace to handle async chain stack traces
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>
2026-06-04 14:08:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1aa2926f30 fix: resolve zone mismatch by removing async/unawaited from main
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>
2026-06-04 13:43:55 +02:00
Thomas SharedInbox 771ac691d9 misc. 2026-06-04 13:35:38 +02:00
Thomas Güttler 65ac023622 fix wiget tests. 2026-06-04 12:08:48 +02:00
Thomas Güttler 838eee66bd icon.svg 2026-06-04 11:12:07 +02:00
6b4c2939ab fix: downgrade Flutter to 3.44.0 — cirruslabs image for 3.44.1 not published (#409)
## Summary

- Reverts `.fvmrc` from `3.44.1` to `3.44.0`
- `ghcr.io/cirruslabs/flutter:3.44.1` returns "manifest unknown" — image does not exist on GHCR
- `ghcr.io/cirruslabs/flutter:3.44.0` is confirmed present — CI can pull the toolchain container again

Closes #408

## Test plan

- [x] `docker manifest inspect ghcr.io/cirruslabs/flutter:3.44.0` returns a valid manifest (verified locally)
- [x] `docker manifest inspect ghcr.io/cirruslabs/flutter:3.44.1` returns "manifest unknown" (confirmed root cause)
- [ ] CI pipeline should pass once the toolchain image resolves correctly

🤖 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/409
2026-06-04 08:02:50 +02:00
0195f6e75c fix: bust stale Dagger cache and harden SSH key normalisation in Deployer (#406)
## Summary

Fixes the persistent `Load key "/root/.ssh/id_ed25519": error in libcrypto` failures in the `deploy-apk` and `deploy-linux` CI jobs (and the `website` workflow SSH steps) that have been occurring on every deploy run since the jobs first started running after #369.

Closes #404

### Root cause (diagnosed from run #1516 log)

Two compounding problems were found:

1. **Stale Dagger cache** — The `tr -d \x27\r\x27` normalisation step added in #369 was shown as `CACHED` by Dagger on every subsequent run. Dagger caches by input-content hash; if the very first execution produced a corrupted key file, that broken cached layer is replayed forever.

2. **`.ssh/` directory permissions** — Dagger creates parent directories for secret mounts with 755 permissions. Mounting the raw key directly inside `/root/.ssh/` may cause Dagger to (re-)create that directory with 755 instead of the 700 that OpenSSH requires.

### Changes (`ci/main.go` — `Deployer` function only)

- **Explicit `.ssh` setup**: `mkdir -p /root/.ssh && chmod 700 /root/.ssh` runs before any Dagger secret mount.
- **Move raw-key mount out of `.ssh/`**: Secret mounted at `/tmp/id_ed25519.raw`.
- **Python3 normalisation instead of `tr`**: Handles CRLF, bare-CR, and missing trailing newline. Changing the command changes the Dagger cache key, forcing a fresh read of the current live secret.

## Test plan

- [ ] `deploy-apk` job completes without `error in libcrypto`
- [ ] `deploy-linux` job completes without `error in libcrypto`
- [ ] `publish-android` (Play Store) job continues to succeed

🤖 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/406
2026-06-04 07:15:04 +02:00
cd8c930000 fix: use Builder to get descendant context for Scaffold.of() in bottom nav (#403)
## 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
2026-06-04 06:16:24 +02:00
b0354c7423 fix: remove delete confirmation dialog from thread view (#402)
## 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
2026-06-04 06:15:55 +02:00
582f6764eb fix: snack bar now auto-dismisses after delete in mail detail view (#401)
## 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
2026-06-04 06:15:37 +02:00
674d402ff9 feat: pre-fetch email bodies for offline access (#400)
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
2026-06-04 06:15:00 +02:00
Bot of Thomas Güttler 09e20dd85f fix: remove stale .github/workflows/ci.yml to stop double CI trigger (#393) 2026-06-04 02:54:11 +02:00
Bot of Thomas Güttler c1d314a621 feat: combined inbox as the default startup view (#376) (#379) 2026-06-04 02:46:59 +02:00
fa5938c7bd fix: silence Dagger output in deploy tasks, only show on failure (#390)
## 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
2026-06-04 02:36:20 +02:00
f92f3debd7 feat: pre-fetch next email body to eliminate loading delay after delete (#381)
## 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
2026-06-04 01:42:16 +02:00
692fa14d4d feat: remember show images per sender (#378)
## 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
2026-06-04 01:41:50 +02:00
5e029a1365 feat: prioritise sent-folder addresses in To/Cc/Bcc autocomplete (#380)
## 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
2026-06-04 00:27:04 +02:00
87244de7da feat: group email headers in full-screen dialog (#374)
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
2026-06-03 22:14:14 +02:00
6d1df2d213 fix: disable Renovate gomod updates for ci/ to prevent artifact failures (#370)
## 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
2026-06-03 22:13:43 +02:00
29c2c7e96c fix: three deploy failures from run #1424 (#369)
## 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
2026-06-03 21:23:13 +02:00
6a097976d3 fix: correct LAST_DEPLOYED_SHA detection so Play Store always gets updated (#364)
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
2026-06-03 19:26:00 +02:00
d847d40ab0 fix: add Renovate custom managers for Dagger version in Dockerfile and DAGGER.md (#365)
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
2026-06-03 19:25:25 +02:00
Thomas SharedInbox 761378f583 Dockerfile. 2026-06-03 17:30:30 +02:00
63da36c18a fix: update OpenTelemetry to v1.44.0 and fix go.sum inconsistency (#363)
## 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
2026-06-03 16:44:04 +02:00
d3bd8dba92 fix: pass commit hash to Hugo so website-verify.sh finds x-version (#362)
## 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
2026-06-03 16:43:26 +02:00
9605c5e3b7 ci: print explicit reason when deploy jobs are skipped (#357)
## 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
2026-06-03 13:27:29 +02:00
1681fb9202 fix: fail fast in CI — parallel hygiene/layer checks, no spurious retries (#350)
## 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
2026-06-03 13:07:37 +02:00
guettlibotandguettli d7a9c2b4f8 chore(deps): update dependency flutter to v3.44.1 (#355)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [flutter](https://flutter.dev) ([source](https://github.com/flutter/flutter)) | patch | `3.44.0` → `3.44.1` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/276) for more information.

>  **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjIwOS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhdXRvbWVyZ2UiLCJkZXBlbmRlbmNpZXMiXX0=-->

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/355
2026-06-03 08:21:25 +02:00
Bot of Thomas Güttler 2747c4e63d chore: migrate CI secrets from Forgejo to SOPS (#354) 2026-06-03 06:37:07 +02:00
dbc9d4dac8 fix: migrate jvmTarget to compilerOptions DSL for Kotlin 2.x (#352)
## 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
2026-06-02 21:10:35 +02:00
Thomas SharedInbox 34351d65a2 chore: dummy change to trigger CI 2026-06-02 17:48:24 +02:00
Thomas Güttler b0a09939c9 chore: migrate all workflows to SSH-based Dagger engine and remove stunnel legacy 2026-06-02 17:40:35 +02:00
Thomas Güttler 8ea8d71f42 fix: format, analyze-fix and update mocks 2026-06-02 17:10:16 +02:00
Thomas Güttler 3520f161e3 fix: update website workflow with correct Dagger setup and SOPS_AGE_KEY 2026-06-02 17:00:54 +02:00
Thomas Güttler ed247baaac fix: use more robust Dagger connection verification 2026-06-02 16:55:18 +02:00
Thomas Güttler 69bd7f5962 fix: use SSH tunnel for Dagger remote connection 2026-06-02 16:52:16 +02:00
Thomas Güttler e0ecac20aa fix: ensure remote DAGGER_HOST is set and use more robust SSH setup 2026-06-02 16:24:56 +02:00
Thomas Güttler f9e0fadb68 fix: use ssh-keyscan to populate known_hosts for Dagger 2026-06-02 16:21:49 +02:00
Thomas Güttler aebc1e508e fix: use ssh-agent for Dagger remote connection 2026-06-02 16:18:06 +02:00
Thomas Güttler 375fd18f9f fix: use full SSH URL for Dagger remote to avoid config include issues 2026-06-02 16:14:51 +02:00
Thomas Güttler ba21b802eb fix: use _EXPERIMENTAL_DAGGER_RUNNER_HOST for Dagger SSH redirection 2026-06-02 13:31:11 +02:00
Thomas Güttler 7974c28102 fix: use absolute path for dagger in ssh wrapper 2026-06-02 13:23:41 +02:00
Thomas Güttler 6303cc5ac1 test: verify simplified ci.yml 2026-06-02 13:22:34 +02:00
Thomas Güttler 9744fe1379 debug: extremely simplify ci.yml 2026-06-02 13:22:05 +02:00
Thomas Güttler 39a65b97e9 test: verify Dagger SSH/SOPS fixes with dummy commit 2026-06-02 13:21:17 +02:00
Thomas Güttler e5c5dc9db8 fix: add IdentitiesOnly=yes to SSH config for Dagger 2026-06-02 13:20:20 +02:00
Thomas Güttler 6703ffd69b fix: use explicit ssh wrapper for dagger commands 2026-06-02 13:19:16 +02:00
Thomas Güttler 43eafbd4c2 debug: simplify workflow triggers to fix parsing error 2026-06-02 13:18:28 +02:00
Thomas Güttler ee1fccf340 fix: use _EXPERIMENTAL_DAGGER_RUNNER_HOST for SSH redirection 2026-06-02 13:16:33 +02:00
Thomas Güttler 5757176937 debug: add SSH connection test to setup_dagger_remote.sh 2026-06-02 12:51:41 +02:00
Thomas Güttler 180035ec55 fix: re-apply ci.yml with clean format 2026-06-02 12:50:39 +02:00
Thomas Güttler 68dabc56d0 test: trigger CI again 2026-06-02 12:48:39 +02:00
Thomas Güttler 8ee411d1c8 fix: use --output-type json for SOPS decryption 2026-06-02 12:45:34 +02:00
Thomas Güttler ec3ebfa4a3 fix: update CI workflow for SSH/SOPS and SOPS_AGE_KEY 2026-06-02 12:44:35 +02:00
Thomas Güttler d206c5aa79 test: trigger CI to verify Dagger SSH/SOPS pipeline 2026-06-02 12:42:20 +02:00
Thomas Güttler 1e2d1b6063 chore: migrate to SOPS and SSH for Dagger engine access 2026-06-02 11:10:29 +02:00
guettlibotandBot of Thomas Güttler 9290d87a7f chore(deps): update plugin org.jetbrains.kotlin.android to v2.3.21 (#327) 2026-06-01 21:50:03 +02:00
Bot of Thomas Güttler 264ce7e349 fix: guard against empty IMAP fetch message list (#346) 2026-06-01 21:48:21 +02:00
Bot of Thomas Güttler b3f5ad4110 fix: add try-catch to _measureHeight() in secure_email_webview.dart (#345) 2026-06-01 21:47:53 +02:00
Bot of Thomas Güttler 7e3308cb94 fix: pin intl dependency to ^0.20.2 instead of any (#344) 2026-06-01 21:47:50 +02:00
Bot of Thomas Güttler c6e7c035f2 fix: guard threadEmails.last against empty list (#343) 2026-06-01 21:47:47 +02:00
Bot of Thomas Güttler 71ec760365 test: add agentloop code test comment to DEVELOPMENT.md (#336) 2026-06-01 21:47:44 +02:00
guettlibotandBot of Thomas Güttler 2a9a5f339a chore(deps): update plugin com.android.application to v8.13.2 (#326) 2026-06-01 21:47:39 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ea5d119706 fix: add timeouts to dagger query, docker info, and portfile loop (#347)
Three unguarded blocking calls caused CI to hang until the 60-min timeout:
- dagger query prune steps had no timeout; || true only catches errors, not hangs
- docker info (added in d905cd6) had no timeout if Docker socket is unresponsive
- until portfile loop in check-dagger spun forever if otel-receiver.py crashed

Fixes: timeout 120 on all dagger query prune calls, timeout 30 on docker info,
and a kill -0 process-alive guard on the portfile until loop with fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:43:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 968db75c69 feat: replace agent_loop.py with agentloop
Switch from the bespoke 1136-line Python orchestrator to the community
agentloop tool (https://github.com/guettli/agentloop). The new tool
handles the issue → agent → PR pipeline via a label state machine using
loop/plan and loop/code labels, running every 5 minutes via cron.

Removes: scripts/agent_loop.py, scripts/test_agent_loop.py
Removes: .forgejo/workflows/monitor.yml (no heartbeat concept in agentloop)
Updates: AGENTS.md to document the new loop/ label workflow

agentloop config lives in ~/agentloop/loop/sharedinbox/ on the host.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:20:48 +02:00
Bot of Thomas Güttler d905cd653f fix: check Docker availability before falling back to local Dagger engine (#329) (#333) 2026-05-29 23:19:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e21cde0a3c fix: allow forgejo-actions as issue author in agent loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:52:56 +02:00
Bot of Thomas Güttler 50a6678ec2 feat: reimplement user preferences, archive, configurable navigation (#315) (#324) 2026-05-29 19:08:12 +02:00
Bot of Thomas Güttler 91083218d4 fix: diff from last deployed SHA to catch all changes since last deploy (#320) (#332) 2026-05-29 17:34:21 +02:00
Bot of Thomas Güttler adc4eb6f6d feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325) (#330) 2026-05-29 12:53:18 +02:00
Bot of Thomas Güttler 05d00bdf09 fix: move overflow actions into popup menu so three-dot menu is always visible (#312) (#323) 2026-05-28 07:19:11 +02:00
Bot of Thomas Güttler c45775be92 fix: move sync health report to own row below each account (#311) (#322) 2026-05-28 06:53:11 +02:00
47fc534a8d fix: disable github-actions manager to suppress GitHub token warning (#285) (#306)
## 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
2026-05-28 05:03:02 +02:00
Bot of Thomas Güttler a5928c1aa6 fix: add _tea_get and merged-PR catch-up to close issues on merge (#305) (#310) 2026-05-28 00:07:13 +02:00
Bot of Thomas Güttler 7f3cd43d6e feat: add --dangerously-skip-permissions to claude --resume output (#304) (#309) 2026-05-27 23:48:12 +02:00
Bot of Thomas Güttler f0f210e5ab feat: configurable next action after single mail view (#300) (#308) 2026-05-27 23:33:14 +02:00
Bot of Thomas Güttler 41550eb4b5 feat: configurable menu bar position for mailbox view (#298) (#303) 2026-05-27 22:07:12 +02:00
Bot of Thomas Güttler 633fc5d9da fix: show full discrepancy details in account list (#296) (#301) 2026-05-27 21:20:19 +02:00
Bot of Thomas Güttler 14f64cd2a5 feat: show URL tooltip on long-press of unsubscribe chip (#294) (#295) 2026-05-27 21:02:30 +02:00
Bot of Thomas Güttler 5ddfe68467 feat: catch up Renovate PRs with passing CI in agent loop (#289) (#293) 2026-05-27 20:09:13 +02:00
Bot of Thomas Güttler f42522e6d0 Merge pull request 'chore(deps): update gradle to v8.14.5' (#274) from renovate/gradle-8.x into main 2026-05-27 20:02:49 +02:00
guettlibotandBot of Thomas Güttler db78d590ca chore(deps): update opentelemetry-go monorepo to v0.19.0 (#279) 2026-05-27 20:00:52 +02:00
Bot of Thomas Güttler dbb29fb76a fix: rename workflow to Update Website and guard verify step (#282) (#283) 2026-05-27 20:00:39 +02:00
guettlibotandBot of Thomas Güttler 2d2d12cc24 chore(deps): update dependency flutter to v3.44.0 (#278) 2026-05-27 20:00:08 +02:00
guettlibotandBot of Thomas Güttler 3f0b3e5096 fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 (#275) 2026-05-27 19:59:21 +02:00
guettlibotandBot of Thomas Güttler 38fab3f5fc chore(deps): update gradle to v8.14.5 (#274) 2026-05-27 19:58:36 +02:00
Bot of Thomas Güttler e2b08e07b7 fix: prevent HTML email content from being cut off (#288) (#292) 2026-05-27 19:52:14 +02:00
Bot of Thomas Güttler c0dd13be5d feat: align single and multi-mail actions, add archive (#287) (#291) 2026-05-27 19:36:13 +02:00
Bot of Thomas Güttler 4e32984ecc fix: prompt to create or pick folder when archive is missing (#286) (#290) 2026-05-27 19:06:37 +02:00
Bot of Thomas Güttler 2f975829e5 feat: auto-merge safe Renovate PRs via CI (#277) (#284) 2026-05-27 09:37:15 +02:00
Bot of Thomas Güttler 73bbfd2694 fix: add explicit note that app settings are never uploaded (#280) (#281) 2026-05-27 08:25:20 +02:00
Thomas SharedInbox 49e6b335d9 better err msg in agent-loop. 2026-05-27 08:14:42 +02:00
guettlibot 96bd351512 chore(deps): update gradle to v8.14.5 2026-05-27 06:06:19 +00:00
Thomas SharedInboxandClaude Sonnet 4.6 e8234981c5 fix(renovate): run sed as root to patch read-only dist files
The /usr/local/renovate/dist directory is owned by root.
Temporarily switch to root for the sed patch, then back to ubuntu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:55:31 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cf94c7c1fb fix(renovate): patch forgejo+gitea pr-cache.js at /dist/ path
Files are under dist/ not lib/, and we need to patch both
forgejo and gitea platform caches since platform=forgejo is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:39:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 92183a3eb2 chore(renovate): diagnostic step to find pr-cache.js location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:29:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4e8a5ff968 fix(renovate): use find to locate pr-cache.js before patching
The file is not at the assumed path; use find to locate it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:19:48 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 33f1c5a9d4 fix(renovate): patch pr-cache.js to use limit=10 for Codeberg
Codeberg's API times out (504) on GET /pulls?state=all&limit=100
but completes in ~9s at limit=10. Patch the compiled pr-cache.js
in the renovate:43 image before running to replace the hardcoded
20/100 page sizes with 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:18:02 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0552b7a48c fix(renovate): pre-seed PR cache to avoid Codeberg 504 on initial sync
Codeberg's API times out (504) when fetching 100 closed PRs
(GET /pulls?state=all&limit=100), but succeeds with limit=20.
Renovate uses limit=100 on the first run and limit=20 on incremental
syncs. Pre-seeding the repository cache with one dummy entry tricks
Renovate into using the limit=20 incremental path from the start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:09:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2f0da5b475 fix(renovate): upgrade to renovate:43 with forgejo platform
renovate/renovate:39 did not support "forgejo" as a platform name;
v43 does. Upgrade the image and restore the correct platform name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:28:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a1f8bb5994 fix: use RENOVATE_PLATFORM=gitea for renovate/renovate:39
renovate/renovate:39 does not recognise "forgejo" as a platform name;
the correct value is "gitea", which covers Forgejo/Gitea instances
including Codeberg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:27:15 +02:00
Bot of Thomas Güttler 6714e330cc Merge pull request 'feat: run Firebase tests once daily via dedicated workflow (#272)' (#273) from issue-272-fix into main 2026-05-26 17:20:37 +02:00
Thomas SharedInbox a8d6ec5861 fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:22:23 +02:00
Thomas SharedInbox 491a220fbb fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:21:50 +02:00
Thomas SharedInbox e22c4aa88d fix: use Dagger for website deploy and record Renovate Bot completion (#267, #268) 2026-05-26 15:09:59 +02:00
Thomas SharedInbox 4bc24072f0 feat: run Firebase tests once daily via dedicated workflow (#272) 2026-05-26 15:09:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 720c54433a feat: run Firebase tests once daily via dedicated workflow (#272)
Move Android Firebase instrumented tests out of deploy.yml into a new
firebase-tests.yml workflow that runs once per day (3 AM UTC) and only
when Firebase-relevant files changed in the last 24 hours. On failure,
the workflow automatically creates a Forgejo issue labelled "Ready" with
instructions to find the root cause and fix it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:48:10 +02:00
Thomas SharedInbox dd26086220 docs: record Renovate Bot completion and close issue #257 (#268)
All required components (renovate.json, ci/main.go Renovate() function,
.forgejo/workflows/renovate.yml, Taskfile.yml renovate task) were already
in main. Closed issue #257.
2026-05-26 08:19:49 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2747ff0dca fix: use Dagger for website deploy instead of bare hugo call (#267)
Replace `task website-deploy` (which calls `hugo` directly and fails
because Hugo is not installed on the CI runner) with the Dagger-based
`task publish-website`, matching the pattern used by other jobs in
deploy.yml. Also adds Dagger remote engine setup, runner tool checks,
SSH_KNOWN_HOSTS secret, a timeout, and TLS credential cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:01:37 +02:00
Bot of Thomas Güttler f57a8c502d feat: syncLog add Copy button, stack trace, isPermanent (#266) (#269) 2026-05-26 07:55:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c4efb56a0c feat: syncLog add Copy button, stack trace, isPermanent, Android device info (#266)
- Schema v33: add error_stack_trace and is_permanent columns to sync_logs
- SyncLogEntry gains stackTrace and isPermanent fields; SyncLogRepository.log()
  gains matching optional parameters; IMAP and JMAP sync loops forward the
  stack trace string and isPermanent flag when writing error entries
- New lib/ui/utils/about_markdown.dart utility shared by AboutScreen and the
  sync log copy feature; builds the markdown table including device info
- AboutScreen uses the utility (refactored to remove duplicate _buildMarkdown)
- SyncLogScreen: subtitle shows "Error (permanent)" for permanent errors;
  expanded view shows stack trace in red monospace; each tile has a Copy
  button that copies a markdown summary of the entry plus the About section
- Migration test updated for v33; new repo test for stackTrace/isPermanent
- check_coverage.dart excludes lib/ui/utils/about_markdown.dart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:49:56 +02:00
c97e3d505f fix: skip deploy when HEAD already successfully deployed (#264) (#265)
## 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
2026-05-26 07:35:18 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2bb7ac11df feat: add runner tools check and LOG_LEVEL to Renovate Bot (#257)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:24:47 +02:00
Bot of Thomas Güttler 8709e9f38d feat: add Locale, Text Scale, DB Schema Version, Device Model to About page (#258) (#263) 2026-05-25 22:18:09 +02:00
Bot of Thomas Güttler 7997ff0980 feat: Reply All dialog on Reply button, add Mark as Spam (#260) (#261) 2026-05-25 21:51:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2359c7d586 feat: run Renovate via Dagger on daily schedule (#257, #216)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:01 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4ada3798b6 feat: run Renovate via Dagger on daily schedule (#257, #216)
Adds a Renovate() Dagger function using the forgejo platform and a
.forgejo/workflows/renovate.yml workflow triggered at 06:00 UTC daily.
Uses RENOVATE_FORGEJO_TOKEN secret; no dedicated Renovate service account needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:26:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 07ac73dcb2 feat: add Renovate Bot configuration (#216)
Adds renovate.json to enable automated dependency updates for
pub (pubspec.yaml), Dockerfile, and Forgejo Actions workflows.
The github-actions manager fileMatch is extended to cover
.forgejo/workflows/ in addition to the default .github/ path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:25:51 +02:00
Bot of Thomas Güttler bb475a2350 fix: auto-resolve merge failures instead of asking for manual merge (#253) (#256) 2026-05-25 19:38:07 +02:00
Thomas SharedInbox 63f7463ced feat: add Gradle cache to Android release builds (#251) (#252) 2026-05-25 19:27:06 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0175c9e5a5 feat: add Gradle cache to Android release builds (#251)
Introduce androidBase() and firebaseBase() helpers that wrap setup() with
the Gradle named-cache volume, mirroring the pattern already used in
BuildAndroidDebugApks(). Use these in BuildAndroidRelease(), setupKeystore(),
and BuildAndroidDebugApks() so Gradle dependencies survive Dagger
execution-cache misses instead of being re-downloaded on every source change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:26:17 +02:00
Bot of Thomas Güttler 9f9bf14bbe feat: inject GIT_HASH into Dagger builds so About page shows git hash (#249) (#250) 2026-05-25 15:10:12 +02:00
Bot of Thomas Güttler a7783d46cf fix: disable Save button when no password available; fix changelog fetch-depth (#246, #229) (#248) 2026-05-25 14:47:25 +02:00
Bot of Thomas Güttler 3868c160d3 fix: disable Try connection button when no password is available (#235) (#247) 2026-05-25 14:30:13 +02:00
Bot of Thomas Güttler 50fc012e81 Merge pull request 'fix: show password required error instead of crashing when no stored password (#235)' (#238) from issue-235-fix into main 2026-05-25 13:00:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 94b20f50be style: format edit_account_screen_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:49:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 885906b204 fix: show password required error instead of crashing when no stored password (#235)
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>
2026-05-25 12:49:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 06df3ee200 feat: monitor agent loop health every 2 hours (#217)
- Track a heartbeat timestamp in ~/.sharedinbox-agent-heartbeat at the
  start of each _run_loop() invocation so we can tell when it last ran.
- Add `agent_loop.py monitor` subcommand that exits 1 with a WARNING
  message if the heartbeat is missing, corrupted, or older than 2 hours.
- Add .forgejo/workflows/monitor.yml scheduled workflow that runs the
  monitor check every 2 hours on the self-hosted runner; a CI failure
  serves as the warning when the loop is stalled.
- Add 7 unit tests covering all monitor / heartbeat scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:48:45 +02:00
Bot of Thomas Güttler e03c7708ba feat: show app version as link on crash screen and in MD report (#236) (#245) 2026-05-25 11:40:53 +02:00
27bef3356e fix: skip catch-up merge retry when issue has State/Question (#239) (#242)
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
2026-05-25 09:21:23 +02:00
Bot of Thomas Güttler 32ba916cbf fix: trigger deploy on script changes, add changelog dep, deepen fetch (#228) (#233) 2026-05-24 21:05:10 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 86e12ffe72 fix: add fgj to nix store PATH in deploy.sh
fgj is in the nix store but was not included in the PATH glob loop,
causing `FileNotFoundError: 'fgj'` on every cron run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:02:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f4a052bedc feat: add State/ToPlan planning phase to agent loop
Issues labelled State/ToPlan are now picked up by a dedicated planning
agent before any implementation happens. The agent posts a plan as an
issue comment, then the loop transitions the label to State/Planned and
leaves a resume command in a follow-up comment. A human reviews the plan
and manually promotes the issue to State/Ready to trigger implementation.

Planning agents run at higher priority than Ready issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:56:46 +02:00
Thomas SharedInbox b2c11e0c63 Revert "feat: keep secrets in sync via age-encrypted master key (#208) (#223)"
This reverts commit 96b1660b59.
2026-05-24 18:39:23 +02:00
Bot of Thomas Güttler 09c90c244b fix: load changelog via DefaultAssetBundle for testability (#214) (#225) 2026-05-24 17:50:10 +02:00
Bot of Thomas Güttler 357ed9af31 fix: about page version unknown and link crash on Android (#213) (#224) 2026-05-24 17:20:09 +02:00
Bot of Thomas Güttler 96b1660b59 feat: keep secrets in sync via age-encrypted master key (#208) (#223) 2026-05-24 16:35:10 +02:00
Bot of Thomas Güttler e7ff9243c9 feat: add build mode, Dart version, timestamp to crash report (#205) (#222) 2026-05-24 16:10:09 +02:00
Bot of Thomas Güttler d51e67ddcc fix: probe scanner method channel to detect MissingPluginException (#204) (#221) 2026-05-24 15:55:08 +02:00
Bot of Thomas Güttler 43068509d2 fix: show live countdown with seconds on receive account screen (#203) (#220) 2026-05-24 15:15:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 d9b8748631 fix: filter _latest_main_ci_run by workflow_id == ci.yml
Forgejo reports deploy.yml (scheduled/dispatch) runs with event=push
and prettyref=main, identical to ci.yml push runs. The event-only
filter was insufficient — adding workflow_id == "ci.yml" prevents
deploy.yml runs from blocking or triggering false CI fix agents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:07:00 +02:00
Bot of Thomas Güttler 50ae7df8a3 fix: fall back to text input when mobile_scanner plugin is unavailable (#202) (#219) 2026-05-24 14:55:07 +02:00
Bot of Thomas Güttler 7dd5800064 perf: cache Linux engine artifacts via flutter precache --linux (#129) (#218) 2026-05-24 14:30:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 77e581299d fix: filter out schedule/deploy workflow runs in CI checks
_latest_main_ci_run() was using event != pull_request which still
matched deploy.yml schedule runs when their prettyref == "main",
blocking the loop from picking up new issues.

_latest_ci_run_for_branch() had the same issue: the else branch matched
any non-pull_request event including schedule runs.

Both functions now explicitly filter for event == "push" only.

Tests updated: rename _latest_ci_run → _latest_main_ci_run, mock
_open_issue_prs to prevent real API calls in unit tests, and update
_find_pr_for_branch side_effect to reflect the upstream post-merge
PR-still-open verification check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:08:13 +02:00
Bot of Thomas Güttler 37eca207c6 fix: pin SSH host key via known_hosts instead of StrictHostKeyChecking=no (#161) (#181) 2026-05-24 13:00:04 +02:00
Bot of Thomas Güttler 5925cee4f2 fix: show git hash as clickable link above stacktrace (#201) (#211) 2026-05-24 12:56:27 +02:00
Bot of Thomas Güttler a8603edfc3 fix: verify PID belongs to claude before SIGKILL (#160) (#163) 2026-05-24 12:55:08 +02:00
Bot of Thomas Güttler 0293cb5845 fix: stop retrying on MissingPluginException from flutter_secure_storage (#200) (#209) 2026-05-24 08:50:06 +02:00
Bot of Thomas Güttler 30bcc8a314 fix: skip CI jobs when unrelated files change (#144) (#207) 2026-05-24 08:30:10 +02:00
Bot of Thomas Güttler ac0e16adcb feat: about page - sharedinbox.de heading link and git commit row (#199) (#206) 2026-05-24 08:10:07 +02:00
Thomas SharedInbox 3f946dfca0 fix: switch Play Store upload from httplib2 to requests
The Play Store AAB upload was failing with httplib2.error.RedirectMissingLocation
when Google's API returned a redirect during the resumable upload initiation.
Switched from google-api-python-client (which uses httplib2 internally) to
pure requests-based AuthorizedSession, which handles redirects correctly.

Closes #198
2026-05-24 07:52:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c517f604e0 test: update deploy_playstore tests for requests-based transport
The previous tests patched google_auth_httplib2 and googleapiclient which
no longer exist in the new implementation. Rewrite to mock AuthorizedSession
and _upload_aab_resumable, covering the same scenarios: happy path, retry
on transient errors, backoff delays, and exhausted attempts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:40:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7d393ec818 fix: switch Play Store upload from httplib2 to requests
httplib2 treats 308 Resume Incomplete responses (used by Google's
resumable upload API) as redirects and raises RedirectMissingLocation
when the response lacks a Location header. Switch to
google.auth.transport.requests.AuthorizedSession + direct HTTP calls
so the upload uses the requests library, which handles 308 correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 07:32:22 +02:00
Bot of Thomas Güttler 5c38357033 fix: limit dagger-data volume growth by pruning named caches (#193) (#197) 2026-05-24 06:00:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7715190cbf fix: retry AAB upload on RedirectMissingLocation with exponential backoff
Adds a 3-attempt retry loop around the resumable AAB upload that catches
httplib2.error.RedirectMissingLocation (a transient network error) and
retries with exponential backoff (10s, 20s). A fresh MediaFileUpload is
created on each attempt because resumable upload objects cannot be reused
after failure. Also adds TestUploadRetry covering the retry path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 05:30:24 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 80cde04d87 fix: retry AAB upload on RedirectMissingLocation with exponential backoff (#186)
Wrap the resumable bundle upload in a loop of up to _MAX_UPLOAD_ATTEMPTS (3)
attempts. On httplib2.error.RedirectMissingLocation, recreate MediaFileUpload
(resumable uploads cannot reuse the same object) and wait 10 s / 20 s before
retrying. After all attempts are exhausted, raise RuntimeError chained to the
last exception. Add tests covering the retry path, backoff delays, fresh
MediaFileUpload on each attempt, and exhaustion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:59:05 +02:00
Bot of Thomas Güttler 83060bc1bf fix: add timeout and retries to Play Store upload (#185) (#195) 2026-05-24 04:45:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 fb6f2cca68 fix: add timeout and retries to Play Store upload (#185)
Switch deploy_playstore.py from requests/AuthorizedSession to the
googleapiclient.discovery client with google-auth-httplib2, so that
AuthorizedHttp(timeout=300) enforces a hard socket timeout on all
requests and num_retries=3 on every .execute() call enables automatic
retries for transient failures.

Update flake.nix and ci/main.go to install the new dependencies
(google-api-python-client, google-auth-httplib2, httplib2) instead of
the old google-auth + requests pair.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 04:38:36 +02:00
Bot of Thomas Güttler 71ccf24d0c fix: survive permanently broken path_provider channel on Android (#192) (#194) 2026-05-24 03:50:07 +02:00
179 changed files with 14310 additions and 4181 deletions
+6 -2
View File
@@ -4,14 +4,18 @@
# In systemd service: # In systemd service:
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner # ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml # ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
FROM ghcr.io/catthehacker/ubuntu:go-24.04 FROM ghcr.io/catthehacker/ubuntu:go-24.04
# Infrastructure tools required by CI workflows # Infrastructure tools required by CI workflows
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
stunnel4 \ jq \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# SOPS
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
&& chmod +x /usr/local/bin/sops
# Dagger CLI — pinned to match the engine version on the runner host # Dagger CLI — pinned to match the engine version on the runner host
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \ RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh | DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
+20
View File
@@ -0,0 +1,20 @@
name: Chaos Monkey
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
chaos-monkey-backend:
name: Chaos Monkey (backend)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run backend chaos monkey
run: task chaos-monkey-backend
+24 -25
View File
@@ -1,40 +1,39 @@
name: CI name: CI
on: on:
push: push:
branches: [main] branches:
- main
pull_request: pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
name: Full Project Check name: Full Project Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - name: Print runner wait time
with: env:
fetch-depth: 50 FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
- name: Check runner tools
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } runner_start=$(date +%s)
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } created=$(curl -sf --max-time 30 \
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
- name: Setup Dagger Remote Engine (via stunnel) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Run Full Check Suite - name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger run: task check-dagger
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+214 -111
View File
@@ -6,208 +6,311 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test-android-firebase: check-changes:
name: Android Instrumented Tests (Firebase Test Lab) name: Detect Changed Files
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 5
outputs:
android: ${{ steps.diff.outputs.android }}
linux: ${{ steps.diff.outputs.linux }}
steps: steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 0
- name: Check runner tools - name: Detect Android and Linux changes
id: diff
shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } # On workflow_dispatch always build everything
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
- name: Setup Dagger Remote Engine (via stunnel) HEAD_SHA=$(git rev-parse HEAD)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab # Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
env: # does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }} # (per-job records) directly and filter for the task we care about. Filtering at the
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} # task level also distinguishes runs where the Play Store job actually ran from runs
DAGGER_NO_NAG: "1" # where it was skipped — at the run level both show status=success.
run: task test-android-firebase LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
for t in data.get("workflow_runs", []):
if (t.get("workflow_id") == "deploy.yml"
and t.get("name") == "Build & Deploy to Play Store"
and t.get("status") == "success"):
print(t.get("head_sha") or "")
sys.exit(0)
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print("")
PYEOF
)
- name: Cleanup TLS credentials if [ -z "$LAST_DEPLOYED_SHA" ]; then
if: always() echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT"
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff from the last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit. Deploy all targets when the
# SHA is not in local history (shallow clone or very old deploy).
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changed files:"
echo "$CHANGED"
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
if echo "$CHANGED" | grep -qE "$android_re"; then
echo "android=true" >> "$GITHUB_OUTPUT"
echo "Android deploy: TRIGGERED (android-relevant files changed)"
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "android=false" >> "$GITHUB_OUTPUT"
echo "Android deploy: SKIPPED (no android-relevant files changed)"
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
fi
if echo "$CHANGED" | grep -qE "$linux_re"; then
echo "linux=true" >> "$GITHUB_OUTPUT"
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "linux=false" >> "$GITHUB_OUTPUT"
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
fi
deploy-playstore: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps: steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 100
- name: Check runner tools - name: Check runner tools
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store - name: Publish Android to Play Store
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-android run: task publish-android
- name: Cleanup TLS credentials - name: Verify Play Store deployment
if: always() run: |
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid python3 -m venv /tmp/playstore-venv
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
deploy-apk: deploy-apk:
name: Build & Deploy APK to Server name: Build & Deploy APK to Server
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps: steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 100
- name: Check runner tools - name: Check runner tools
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server - name: Build & Deploy APK to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-apk run: task deploy-apk
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
build-linux: build-linux:
name: Build Linux Release name: Build Linux Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.linux == 'true'
steps: steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 100
- name: Check runner tools - name: Check runner tools
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server - name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-linux run: task deploy-linux
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux] needs: [deploy-playstore, deploy-apk, build-linux]
if: always() && vars.DEPLOY_HEALTH_ISSUE != '' if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
)
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue - name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
env: env:
FORGEJO_TOKEN: ${{ github.token }} FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }} FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }} DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ needs.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.deploy-apk.result == 'success' && needs.build-linux.result == 'success' }} ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
run: | run: |
python3 - << 'PYEOF' python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error import os, json, urllib.request, urllib.error
+156
View File
@@ -0,0 +1,156 @@
name: Firebase Tests
on:
schedule:
- cron: '0 3 * * *' # once per day at 3 AM
workflow_dispatch:
jobs:
check-changes:
name: Detect Firebase-Relevant Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Firebase-relevant changes in last 24 hours
id: diff
shell: bash
run: |
# On workflow_dispatch always run
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
| sort -u | grep -v '^$')
if [ -n "$CHANGED" ]; then
echo "Firebase-relevant files changed since $SINCE:"
echo "$CHANGED"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
env:
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Create issue on test failure
if: failure()
env:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ["FORGEJO_URL"].rstrip("/")
run_url = os.environ["RUN_URL"]
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
def api_get(path):
req = urllib.request.Request(f"{api}{path}", headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def api_post(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
"Firebase instrumented tests failed in the daily run.\n\n"
f"**Failed run:** {run_url}\n\n"
"## Steps to resolve\n\n"
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
"3. Close this issue once the root cause is resolved and the tests pass.\n"
)
issue = api_post("/issues", {
"title": title,
"body": body,
"labels": label_ids,
})
print(f"Created issue #{issue['number']}: {issue['html_url']}")
PYEOF
+30
View File
@@ -0,0 +1,30 @@
name: Renovate
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Renovate
env:
DAGGER_NO_NAG: "1"
run: task renovate
+130 -9
View File
@@ -1,6 +1,8 @@
name: Deploy Website name: Update Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -10,23 +12,142 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: check-changes:
name: Build & Deploy Website name: Detect Website Changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps: steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect website changes since last deploy
id: diff
shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: |
# On push or workflow_dispatch always deploy
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent successful "Build & Update Website" task. Forgejo's API
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# (per-job records) directly and filter for the task we care about. Filtering at the
# task level also distinguishes runs where the deploy job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
for t in data.get("workflow_runs", []):
if (t.get("workflow_id") == "website.yml"
and t.get("name") == "Build & Update Website"
and t.get("status") == "success"):
print(t.get("head_sha") or "")
sys.exit(0)
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print("")
PYEOF
)
if [ -z "$LAST_DEPLOYED_SHA" ]; then
echo "::warning::Could not determine last successfully deployed SHA — deploying as a precaution"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::Website deploy SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff from last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit.
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying as a precaution"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changed files:"
echo "$CHANGED"
website_re='^(website/|scripts/website-verify\.sh|\.forgejo/workflows/website\.yml)'
if echo "$CHANGED" | grep -qE "$website_re"; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "::notice::Website deploy TRIGGERED — website-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "::notice::Website deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no website-relevant changes"
fi
deploy:
name: Build & Update Website
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Build & Deploy Website - name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
SSH_USER: ${{ secrets.SSH_USER }} run: scripts/setup_dagger_remote.sh
SSH_HOST: ${{ secrets.SSH_HOST }}
run: task website-deploy - name: Build & Update Website
env:
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Verify Website - name: Verify Website
env: env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }} SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh run: scripts/website-verify.sh
+1
View File
@@ -10,6 +10,7 @@ jobs:
# Disabled until a self-hosted runner with label "windows-runner" is registered. # Disabled until a self-hosted runner with label "windows-runner" is registered.
name: Build & Deploy Windows (Nightly) name: Build & Deploy Windows (Nightly)
runs-on: windows-runner runs-on: windows-runner
timeout-minutes: 90
if: false if: false
steps: steps:
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.41.6" "flutter": "3.44.0"
} }
-249
View File
@@ -1,249 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
analyze-and-test:
name: Analyze & unit test
runs-on: sharedinbox-runner
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Check formatting
run: dart format --set-exit-if-changed .
- name: Analyze
run: flutter analyze --fatal-infos
- name: Unit + widget tests with coverage
run: flutter test test/unit/ test/widget/ --coverage
- name: Coverage gate
run: dart run scripts/check_coverage.dart
integration:
name: Integration tests (Stalwart)
runs-on: sharedinbox-runner
# Run integration tests only on push to main, not on every PR.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: DeterminateSystems/magic-nix-cache-action@v8
- name: Cache FVM Flutter SDK
uses: actions/cache@v4
with:
path: ~/.fvm
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Run integration tests
run: |
nix develop --command bash -c "
fvm install --skip-pub-get &&
fvm flutter pub get &&
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/test.sh
"
integration-ui:
name: UI Integration tests (Stalwart + Xvfb)
runs-on: sharedinbox-runner
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@v14
- uses: DeterminateSystems/magic-nix-cache-action@v8
- name: Install Flutter Linux build dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- name: Cache FVM Flutter SDK
uses: actions/cache@v4
with:
path: ~/.fvm
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Cache Linux debug build
uses: actions/cache@v4
with:
path: |
build/linux
.dart_tool/flutter_build
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
restore-keys: linux-debug-
- name: Run UI integration tests
run: |
nix develop --command bash -c "
fvm install --skip-pub-get &&
fvm flutter pub get &&
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/integration_ui_test.sh
"
build-linux:
name: Build Linux desktop
runs-on: sharedinbox-runner
needs: analyze-and-test
steps:
- uses: actions/checkout@v4
- name: Install GTK3, build tools and libsecret
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Build Linux release
run: flutter build linux --release
deploy:
name: Deploy Linux build & publish website
runs-on: sharedinbox-runner
needs: build-linux
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
steps:
- uses: actions/checkout@v4
- name: Install build & deploy dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev hugo rsync
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 \
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
--date=short > assets/changelog.txt
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Build Linux release
run: |
HASH=$(git rev-parse --short HEAD)
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
- name: Deploy Linux build to server
run: |
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \
"cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
- name: Generate build history pages
run: python3 scripts/generate_build_history.py
- name: Build website
env:
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
run: hugo --source website --minify
- name: Deploy website
run: |
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
+3 -1
View File
@@ -1,5 +1,6 @@
# --- Flutter/Dart --- # --- Flutter/Dart ---
coverage/ coverage/
screenshots/
.dart_tool/ .dart_tool/
.dart-tool/ .dart-tool/
.packages .packages
@@ -28,7 +29,8 @@ android/.gradle/
android/local.properties android/local.properties
android/app/google-services.json android/app/google-services.json
android/key.properties android/key.properties
android/app/src/main/java/io/flutter/plugins/ # android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
.android/ .android/
Android/ Android/
.gradle/ .gradle/
+14 -3
View File
@@ -10,6 +10,11 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/guettli/sync-branch
rev: v0.0.11
hooks:
- id: sync-branch
- repo: local - repo: local
hooks: hooks:
- id: check-no-binary - id: check-no-binary
@@ -27,18 +32,24 @@ repos:
- id: dart-check - id: dart-check
name: dart format (autofix) + check-fast (parallel) name: dart format (autofix) + check-fast (parallel)
language: system language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh' entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: ci-no-direct-dagger - id: ci-no-direct-dagger
name: check for direct dagger calls in workflows (use Task instead) name: check for direct dagger calls in workflows (use Task instead)
language: system language: system
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'" entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: dagger-progress-plain - id: dagger-progress-plain
name: ensure all dagger calls use --progress=plain name: ensure all dagger calls use --progress=plain
language: system language: system
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'" entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: ci-image-exists
name: verify container images in ci/main.go are reachable
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
pass_filenames: false
files: ^(ci/main\.go|\.fvmrc)$
+33 -20
View File
@@ -8,32 +8,45 @@ CLI tool `fgj` is available to query issues/PRs/actions.
## Issue Label Workflow ## Issue Label Workflow
We use issues, follow this label state machine: Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
- **State/Ready** — Issue is available to pick up | Label | Trigger | Outcome |
- **State/InProgress** — Set this when you start working on an issue |---|---|---|
- **State/Question** — Set this when you hit a blocker or need clarification | `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
List open issues ready to pick up: **State machine:**
```bash ```
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}' loop/plan → loop/plan-in-process → loop/plan-done
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-process → loop/merge (via route)
↘ NeedSupervisor (on failure)
loop/merge → loop/merge-in-process → loop/merge-done
↘ NeedSupervisor (on failure)
``` ```
Rules: **Rules:**
- Never start work on an issue without `State/Ready` - Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
- When working via the agent loop: `State/Ready``State/InProgress` is set automatically - An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
by `agent_loop.py` before the agent starts — do **not** set it yourself. - The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
- When working manually: switch to `State/InProgress` as your **first action**: - Planning agents only post a comment — they do NOT write code or open PRs.
```bash - `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
``` **Typical lifecycle for a new feature:**
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
- When done and CI is green, close the issue: ```
```bash 1. Create issue
fgj issue close <NUMBER> 2. Add label loop/plan → agent writes plan as comment
``` 3. Review plan, request changes or approve
4. Add label loop/code → agent implements + opens PR + hands off to merge
5. (Optional) Review PR before it merges
6. Merge agent waits for CI and merges the PR automatically
```
## Code conventions ## Code conventions
+1 -1
View File
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
# Replace 1003 with the actual UID of dagger-svc # Replace 1003 with the actual UID of dagger-svc
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
Environment=XDG_RUNTIME_DIR=/run/user/1003 Environment=XDG_RUNTIME_DIR=/run/user/1003
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080 ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
Restart=always Restart=always
[Install] [Install]
+2
View File
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow ## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands. Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
+72
View File
@@ -0,0 +1,72 @@
## Background
The issue is real but the proposed mechanism (predicted-mail table + Message-ID match at next sync) is more complex than needed. Two IMAP facilities cover this directly:
- **RFC 4315 / 6851 — `UIDPLUS` + `MOVE` response code `COPYUID`**: the server returns the destination UID(s) inline with the `MOVE` (or `COPY`+`EXPUNGE`) response. `enough_mail`'s `GenericImapResult.responseCodeCopyUid` already parses this (`/data/home/.pub-cache/hosted/pub.dev/enough_mail-2.1.7/lib/src/imap/response.dart:53`). Stalwart and every modern IMAP server advertise UIDPLUS. We currently throw this away — `client.uidMove(...)` is called for its side effect and the result is ignored (`lib/data/repositories/email_repository_impl.dart:2320,2335,2354`).
- **Fallback — `UID SEARCH HEADER Message-ID …`**: bounded, deterministic, works on every IMAP server. Needed only when `responseCodeCopyUid` is missing.
The undo path already attempts a Message-ID recovery (`lib/core/services/undo_service.dart:78-84`), and `findEmailByMessageId` exists (`lib/data/repositories/email_repository_impl.dart:1899`), so the building blocks are in place; we just need to remap on the write path instead of guessing on the read path.
There is also a latent race: the row is optimistically moved (`mailbox_path = dest`) before the server flush, but the row still carries the source UID. If `_reconcileDeletedImap` runs against the **destination** mailbox between the optimistic write and the flush, it will not find that UID on the server and will hard-delete the row (`lib/data/repositories/email_repository_impl.dart:758-761`).
## Goal
After an IMAP move, the local email row keeps tracking the same physical message under its new UID, without orphaning, without re-fetching, and without breaking UndoLog references.
## Concrete changes
### 1. Capture the new UID inside the flush
`lib/data/repositories/email_repository_impl.dart``_applyPendingChangeImap`, cases `move`, `snooze`, `unsnooze`:
- Capture the `GenericImapResult` returned by `client.uidMove(...)`.
- Read `result.responseCodeCopyUid`. If non-null, zip `sourceSequence``targetSequence` to map `oldUid → newUid`.
- If null (no UIDPLUS), look up the row's `messageId`. If present: `client.selectMailboxByPath(dest)` then `client.uidSearchMessages(searchCriteria: 'HEADER Message-ID "<id>"')` and take the highest UID. If `messageId` is also missing, log a warning and leave the row to be reconciled out on the next pass (worst case: cache miss, body refetch).
- For each old/new pair, call a new helper `_remapEmailAfterMove(row, newUid, destPath)`.
### 2. New helper `_remapEmailAfterMove` (same file)
Composes `newId = "accountId:destPath:newUid"`, then in one Drift transaction:
- `UPDATE email_bodies SET email_id = newId WHERE email_id = oldId`
- `UPDATE emails SET id = newId, uid = newUid, mailbox_path = destPath WHERE id = oldId`
- Patch any `threads.email_ids_json` containing `oldId` (reuse the JSON helper from the v41 migration path, or just rebuild affected threads via `_updateThread`).
- `UPDATE pending_changes SET resource_id = newId WHERE resource_id = oldId` (in case multiple mutations are queued).
- Call `UndoRepository.remapEmailId(oldId, newId)` (new method, see §3).
`PRAGMA defer_foreign_keys = ON` for the transaction, mirroring the v41 migration pattern.
### 3. UndoLog remap
- `lib/data/repositories/undo_repository_impl.dart`: add `Future<void> remapEmailId(String oldId, String newId)` that loads each `undo_actions` row, rewrites `emailIds[]` entries equal to `oldId` and `originalEmails[].id` entries equal to `oldId`, and re-saves.
- `lib/core/repositories/undo_repository.dart`: add the interface method.
- Inject `UndoRepository` into `EmailRepositoryImpl` via DI (already a singleton in `lib/di.dart`) so `_remapEmailAfterMove` can call it.
- The fallback path in `UndoService.undo` (the Message-ID lookup) becomes a defensive backstop and can stay as-is.
### 4. Reconciliation guard
`_reconcileDeletedImap` (`lib/data/repositories/email_repository_impl.dart:731`): before deleting a row whose UID is absent from the server, query `pending_changes` for a `move`/`snooze`/`unsnooze` row with `resource_id = row.id`. If one exists, skip the delete — the row is mid-flight. Since flush always runs before sync (`SYNC.md:199-200`) the race is narrow but real on retries when flush failed mid-cycle.
### 5. Tests
- `test/unit/fake_imap.dart`: make `uidMove` return a `GenericImapResult` whose `responseCode` is a configurable `"COPYUID <validity> <src> <dest>"` string; add a UIDPLUS-off mode that returns no response code and serves `uidSearchMessages` with a HEADER predicate.
- New `test/unit/repositories/email_repository_move_test.dart`:
- Enqueue a move → flush → assert `emails`, `email_bodies`, `threads`, `pending_changes`, `undo_actions` all carry the new id.
- Same with UIDPLUS-off + HEADER-search fallback.
- Move with no Message-ID and no COPYUID → row is unchanged, warning logged, next reconciliation does not delete it because pending change is still queued.
- New test in the same file: `_reconcileDeletedImap` skips a row that has a queued `move`.
- Extend `test/integration/concurrent_sync_test.dart` (or sibling file) with a real Stalwart move-then-sync-then-undo cycle: assert the row keeps its body, the new UID matches what `UID SEARCH` returns, and undo restores it to the source mailbox.
### 6. Docs
- `SYNC.md` §3: note that IMAP moves remap the local id from the `COPYUID` response (UIDPLUS) or `UID SEARCH HEADER Message-ID` fallback, so `email_bodies` and `undo_actions` stay valid across moves.
- `DB-SYNC.md` IMAP bullets: add "IMAP move remap: local row keeps its body/undo references via `COPYUID` (RFC 4315) or Message-ID header search."
## Non-goals
- No new "predicted mail" table — `COPYUID` + header search is reliable and stateless.
- No change to JMAP moves (JMAP IDs are mailbox-independent).
- No change to `uidValidity` handling — orthogonal.
- No CONDSTORE/QRESYNC — already listed as future work in `DB-SYNC.md`.
## Open question for review
Should the fallback header-search be best-effort-quiet (current plan) or should it surface a `FailedMutation` when both `COPYUID` and `Message-ID` are missing? I lean quiet because the next full sync will still pull the message under its new UID — we'd just lose the cached body and any queued mutations on top of it. Happy to escalate to `FailedMutation` if you'd rather make the data loss visible.
-59
View File
@@ -1,59 +0,0 @@
# Implementation Plan: Secure WebView for HTML Emails (#21)
## Goal
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
## 1. Dependency Management
- **Core**: `webview_flutter` (v4+)
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
### Configuration & Hardening
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
- **Background**: Match the application theme (e.g., transparent or surface color).
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
### Image Blocking Logic
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
- **Toggle Mechanism**:
- Provide a "Load Remote Images" button in the Flutter UI.
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
### Link Interception & Phishing Protection
- Implement `NavigationDelegate.onNavigationRequest`.
- **Process**:
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
2. Block the navigation in the WebView.
3. Trigger a Flutter `showDialog` for confirmation.
- **Phishing Protection Dialog**:
- Show the full URL.
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
- Example: `https://`**`important-bank.com`**`/login`
- "Open in Browser" button uses `url_launcher`.
## 3. Integration Plan
### Step 1: Initialization
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
### Step 2: Replace Renderer in Screens
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
- Remove `flutter_html` imports and dependencies once migration is complete.
## 4. Verification & Security Audit
- **Manual Tests**:
- Open emails with complex HTML layouts.
- Verify images are blocked initially.
- Verify "Load images" works.
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
- **Security Check**:
- Verify that `<script>` tags are not executed.
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
## 5. Potential Challenges
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
-46
View File
@@ -1,46 +0,0 @@
# Snooze Feature Plan
## Goal
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
## Technical Approach
### 1. Metadata Storage (Account Sync)
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
- **JMAP:** Use `keywords`.
- **IMAP:** Use User Flags (keywords).
### 2. Database Changes
- **Migration v22:**
- `Emails` table:
- `snoozedUntil` (DateTime, nullable)
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
- Index on `snoozedUntil`.
### 3. Repository Updates (`EmailRepository`)
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
- Optimistically update local DB.
- Enqueue `snooze` change.
- New method: `Future<int> wakeUpEmails(String accountId)`
- Find local rows where `snoozedUntil <= now`.
- Enqueue `move` back to original mailbox.
- Clear snooze metadata.
### 4. Sync Loop Integration
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
### 5. UI Implementation
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
## Implementation Steps
1. [ ] Database migration and model updates.
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
4. [ ] Update sync logic to parse snooze keywords.
5. [ ] Integrate `wakeUpEmails` into the sync loop.
6. [ ] UI: Snooze picker dialog.
7. [ ] UI: Add Snooze action to list and detail screens.
8. [ ] Testing and validation.
+142 -65
View File
@@ -37,6 +37,8 @@ tasks:
run: once run: once
deps: [_nix-check] deps: [_nix-check]
preconditions: preconditions:
- sh: '[ "$(id -u)" != "0" ]'
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
- sh: test -n "${IN_NIX_SHELL}" - sh: test -n "${IN_NIX_SHELL}"
msg: "Not in nix dev shell. Run: nix develop" msg: "Not in nix dev shell. Run: nix develop"
cmds: cmds:
@@ -56,6 +58,14 @@ tasks:
cmds: cmds:
- echo "Setup complete." - echo "Setup complete."
generate-icons:
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
deps: [_pub-get]
cmds:
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
- fvm flutter pub run flutter_launcher_icons
generate-changelog: generate-changelog:
desc: Generate assets/changelog.txt from git history desc: Generate assets/changelog.txt from git history
cmds: cmds:
@@ -96,34 +106,19 @@ tasks:
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs - scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
codegen: codegen:
desc: Generate Drift DB code (run after any schema change) desc: Generate Drift DB code via Dagger (exports generated files back to host)
deps: [_preflight, _pub-get]
sources:
- lib/**/*.dart
- pubspec.yaml
generates:
- lib/**/*.g.dart
cmds: cmds:
- fvm flutter pub run build_runner build --delete-conflicting-outputs - dagger call --progress=plain -q -m ci --source=. codegen -o .
analyze: analyze:
desc: Static analysis (flutter analyze) desc: Static analysis via Dagger (dart analyze --fatal-infos)
deps: [_preflight, _codegen]
sources:
- lib/**/*.dart
- test/**/*.dart
- pubspec.yaml
- analysis_options.yaml
cmds: cmds:
- scripts/run_analyze.sh - dagger call --progress=plain -q -m ci --source=. analyze
format: format:
desc: Format all Dart source files desc: Format all Dart source files via Dagger (writes back to host)
deps: [_preflight]
sources:
- "**/*.dart"
cmds: cmds:
- fvm dart format lib test - dagger call --progress=plain -q -m ci --source=. format-write -o .
check-mocks: check-mocks:
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner) desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
@@ -136,13 +131,9 @@ tasks:
- scripts/check_mocks_fresh.sh - scripts/check_mocks_fresh.sh
analyze-fix: analyze-fix:
desc: Auto-fix lint issues with dart fix --apply desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
deps: [_preflight]
sources:
- lib/**/*.dart
- test/**/*.dart
cmds: cmds:
- fvm dart fix --apply - dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
test: test:
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing) desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
@@ -177,17 +168,17 @@ tasks:
test-backend: test-backend:
desc: Backend tests against a local Stalwart mail server (via Dagger) desc: Backend tests against a local Stalwart mail server (via Dagger)
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. test-backend - timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
integration-ui: integration-ui:
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger) desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. test-integration - timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
sync-reliability: sync-reliability:
desc: Run sync reliability runner (via Dagger) desc: Run sync reliability runner (via Dagger)
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability - timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
test-android-firebase: test-android-firebase:
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger) desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
@@ -202,7 +193,7 @@ tasks:
ci-graph: ci-graph:
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. graph - timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
stalwart: stalwart:
desc: Start a Stalwart instance for local development (via Dagger) desc: Start a Stalwart instance for local development (via Dagger)
@@ -215,14 +206,16 @@ tasks:
preconditions: preconditions:
- sh: test -n "$SSH_PRIVATE_KEY" - sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set" msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" - HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle: build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds: cmds:
- mkdir -p build/app/outputs/bundle/release - mkdir -p build/app/outputs/bundle/release
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab - HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle: upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger desc: Upload AAB from build/ to Play Store via Dagger
@@ -232,10 +225,11 @@ tasks:
- sh: test -f build/app/outputs/bundle/release/app-release.aab - sh: test -f build/app/outputs/bundle/release/app-release.aab
msg: "AAB not found — run build-android-bundle first" msg: "AAB not found — run build-android-bundle first"
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON - timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
publish-android: publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
deps: [generate-changelog]
preconditions: preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON" - sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set" msg: "PLAY_STORE_CONFIG_JSON is not set"
@@ -244,24 +238,31 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD - HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk: deploy-apk:
desc: Build and deploy Android APK via Dagger desc: Build and deploy Android APK via Dagger
preconditions: preconditions:
- sh: test -n "$SSH_PRIVATE_KEY" - sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set" msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
- sh: test -n "$ANDROID_KEYSTORE_BASE64" - sh: test -n "$ANDROID_KEYSTORE_BASE64"
msg: "ANDROID_KEYSTORE_BASE64 is not set" msg: "ANDROID_KEYSTORE_BASE64 is not set"
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)" - HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website: publish-website:
desc: Build and publish website via Dagger desc: Build and publish website via Dagger
preconditions:
- sh: test -n "$SSH_PRIVATE_KEY"
msg: "SSH_PRIVATE_KEY is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" - HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
check-dagger: check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available) desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -284,8 +285,13 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused" "$DAGGER_OUT"; then if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90
else else
return "$RC" return "$RC"
fi fi
@@ -304,7 +310,16 @@ tasks:
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE" rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
} }
trap cleanup EXIT trap cleanup EXIT
until [ -s "$PORTFILE" ]; do sleep 0.05; done until [ -s "$PORTFILE" ]; do
sleep 0.05
if ! kill -0 "$RECV_PID" 2>/dev/null; then
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
retry_dagger dagger call --progress=plain -q -m ci --source=. check
RC=$?
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
exit $RC
fi
done
PORT=$(cat "$PORTFILE") PORT=$(cat "$PORTFILE")
retry_dagger env \ retry_dagger env \
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \ OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
@@ -315,6 +330,20 @@ tasks:
wait "$RECV_PID" 2>/dev/null || true wait "$RECV_PID" 2>/dev/null || true
exit $RC exit $RC
dagger-prune:
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
cmds:
- |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
renovate:
desc: Run Renovate bot against the repository via Dagger
preconditions:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android: integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2) desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
deps: [_preflight, _android-sdk-check, _android-avd-setup] deps: [_preflight, _android-sdk-check, _android-avd-setup]
@@ -362,28 +391,48 @@ tasks:
msg: "SSH_USER is not set" msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST" - sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set" msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD) HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d) DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz" TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL" DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
# Merge with any existing latest.json so we don't overwrite the windows key # Merge with any existing latest.json so we don't overwrite the windows key
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'") EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true) WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \ echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \ echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi fi
echo "Uploaded $TARBALL and updated latest.json" echo "Uploaded $TARBALL and updated latest.json"
deploy-bugreport:
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
preconditions:
- sh: test -n "$SSH_USER"
msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
ssh "root@$SSH_HOST" "systemctl restart bugreport"
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
build-windows-release: build-windows-release:
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
deps: [_pub-get, generate-changelog] deps: [_pub-get, generate-changelog]
@@ -405,24 +454,28 @@ tasks:
msg: "SSH_USER is not set" msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST" - sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set" msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD) HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d) DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
ZIPFILE="sharedinbox-windows-x64-$HASH.zip" ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd - cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE" scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE" DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'") EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true) LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
if [ -n "$LINUX_URL" ]; then if [ -n "$LINUX_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \ echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else else
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \ echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi fi
echo "Uploaded $ZIPFILE and updated latest.json" echo "Uploaded $ZIPFILE and updated latest.json"
@@ -476,18 +529,10 @@ tasks:
cmds: cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled" - ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set"
cmds:
- python3 scripts/deploy_playstore.py
build-android-bundle-local: build-android-bundle-local:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger) desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog] deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
dotenv: [".env"]
method: timestamp method: timestamp
sources: sources:
- lib/**/*.dart - lib/**/*.dart
@@ -496,7 +541,14 @@ tasks:
generates: generates:
- build/app/outputs/bundle/release/app-release.aab - build/app/outputs/bundle/release/app-release.aab
cmds: cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled" - sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
cmds:
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
deploy-android: deploy-android:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
@@ -523,7 +575,7 @@ tasks:
run: run:
desc: Run the app on Linux desktop desc: Run the app on Linux desktop
deps: [_preflight, _linux-deps-check, _pub-get] deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
cmds: cmds:
- fvm flutter run -d linux --no-pub - fvm flutter run -d linux --no-pub
@@ -572,14 +624,18 @@ tasks:
msg: "SSH_USER is not set" msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST" - sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set" msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
HASH=$(git rev-parse --short HEAD) HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d) DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH" REMOTE_DIR="public_html/builds/$DATE_PATH"
APK_NAME="sharedinbox-mua-$HASH.apk" APK_NAME="sharedinbox-mua-$HASH.apk"
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp -o StrictHostKeyChecking=no \ scp \
build/app/outputs/flutter-apk/app-release.apk \ build/app/outputs/flutter-apk/app-release.apk \
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME" "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
echo "Uploaded $APK_NAME to $REMOTE_DIR" echo "Uploaded $APK_NAME to $REMOTE_DIR"
@@ -608,18 +664,23 @@ tasks:
website-deploy: website-deploy:
desc: Deploy the website via rsync to public_html desc: Deploy the website via rsync to public_html
deps: [website-build] deps: [website-build]
preconditions:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- | - |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
rsync -avz --delete \ rsync -avz --delete \
--exclude='*.apk' \ --exclude='*.apk' \
--exclude='*.tar.gz' \ --exclude='*.tar.gz' \
-e "ssh -o StrictHostKeyChecking=no" \
website/public/ \ website/public/ \
${SSH_USER}@${SSH_HOST}:public_html/ ${SSH_USER}@${SSH_HOST}:public_html/
check-fast: check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration) desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks] cmds:
- dagger call --progress=plain -q -m ci --source=. check-fast
check-layers: check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed) desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
@@ -646,6 +707,11 @@ tasks:
fi fi
echo "Hygiene check passed." echo "Hygiene check passed."
check-ci-images:
desc: Verify that all container images referenced in ci/main.go are reachable
cmds:
- scripts/check_ci_images.sh
_integrations: _integrations:
internal: true internal: true
run: once run: once
@@ -658,6 +724,17 @@ tasks:
cmds: cmds:
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}" - scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
screenshots:
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
deps: [_preflight, _codegen]
cmds:
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
chaos-monkey-backend:
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
check: check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test] deps: [analyze, build-linux, test]
-1
View File
@@ -4,7 +4,6 @@ gradle-wrapper.jar
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
GeneratedPluginRegistrant.java
.cxx/ .cxx/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
+18 -19
View File
@@ -16,19 +16,23 @@ android {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
signingConfigs { val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
create("release") {
// Hardcoded alias matching t.sh if (ksPath != null) {
keyAlias = "upload" signingConfigs {
// Use the same password for both key and keystore create("release") {
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") keyAlias = "upload"
storePassword = pass val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
keyPassword = pass storePassword = pass
storeFile = file("upload-keystore.jks") keyPassword = pass
storeFile = file(ksPath)
}
} }
} }
@@ -44,14 +48,9 @@ android {
buildTypes { buildTypes {
release { release {
// Use the signing config defined above for release builds. if (ksPath != null) {
// If the keystore file exists (e.g. in CI or manually placed), sign it. signingConfig = signingConfigs.getByName("release")
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
} }
isMinifyEnabled = false isMinifyEnabled = false
isShrinkResources = false isShrinkResources = false
ndk { ndk {
@@ -67,7 +66,7 @@ flutter {
dependencies { dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26. // Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// integration_test is a dev dependency; the Flutter plugin loader adds it as // integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main) // debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation // references its class in all variants. Make it available for release compilation
@@ -0,0 +1,89 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.4.0" apply false
} }
include(":app") include(":app")
+1 -49
View File
@@ -2,52 +2,4 @@ module dagger/ci
go 1.26.2 go 1.26.2
require ( require golang.org/x/sync v0.20.0
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
github.com/Khan/genqlient v0.8.1
github.com/dagger/otel-go v1.43.0
github.com/vektah/gqlparser/v2 v2.5.33
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)
require (
github.com/99designs/gqlgen v0.17.90 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
go.opentelemetry.io/otel/log v0.17.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
-95
View File
@@ -1,97 +1,2 @@
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+301 -94
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"dagger/ci/internal/dagger" "dagger/ci/internal/dagger"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -148,16 +149,33 @@ if __name__ == "__main__":
` `
type Ci struct { type Ci struct {
Source *dagger.Directory Source *dagger.Directory
FlutterVersion string
} }
func New( func New(
ctx context.Context,
// +defaultPath=".." // +defaultPath=".."
source *dagger.Directory, source *dagger.Directory,
) *Ci { ) (*Ci, error) {
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
}
var fvmrc struct {
Flutter string `json:"flutter"`
}
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
}
if fvmrc.Flutter == "" {
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
}
return &Ci{ return &Ci{
FlutterVersion: fvmrc.Flutter,
Source: source.Filter(dagger.DirectoryFilterOpts{ Source: source.Filter(dagger.DirectoryFilterOpts{
Include: []string{ Include: []string{
".fvmrc",
"lib/", "lib/",
"test/", "test/",
"assets/", "assets/",
@@ -173,7 +191,7 @@ func New(
"website/", "website/",
}, },
}), }),
} }, nil
} }
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts. // toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
@@ -181,7 +199,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container { func (m *Ci) toolchain() *dagger.Container {
return dag.Container(). return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6"). From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
@@ -195,7 +213,8 @@ func (m *Ci) toolchain() *dagger.Container {
WithUser("ci"). WithUser("ci").
WithExec([]string{"/bin/sh", "-c", WithExec([]string{"/bin/sh", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}) `yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
} }
// Base is the Flutter toolchain container with mutable cache mounts attached. // Base is the Flutter toolchain container with mutable cache mounts attached.
@@ -285,6 +304,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
}) })
} }
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func (m *Ci) androidBase() *dagger.Container {
return m.setup(m.androidSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func (m *Ci) firebaseBase() *dagger.Container {
return m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// linuxSrc is the source subset for Linux builds and integration tests. // linuxSrc is the source subset for Linux builds and integration tests.
func (m *Ci) linuxSrc() *dagger.Directory { func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{ return m.Source.Filter(dagger.DirectoryFilterOpts{
@@ -318,12 +352,23 @@ func (m *Ci) Hugo() *dagger.Container {
} }
// Deploy container for rsync/ssh // Deploy container for rsync/ssh
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container { func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
return dag.Container(). return dag.Container().
From("alpine:3.21"). From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}). WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). // Create .ssh with strict permissions before Dagger mounts anything there,
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519") // so the directory is 700 (not Dagger's default 755).
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
// Mount the raw key outside .ssh so Dagger cannot override the directory
// permissions we just set above.
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
// Using Python3 (not tr) changes the Dagger cache key so stale cached
// results from the old tr-based step are not reused.
WithExec([]string{"python3", "-c",
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
} }
// Stalwart mail server service for backend and integration tests. // Stalwart mail server service for backend and integration tests.
@@ -343,7 +388,7 @@ func (m *Ci) Stalwart() *dagger.Service {
return dag.Container(). return dag.Container().
From("stalwartlabs/stalwart:v0.14.1"). From("stalwartlabs/stalwart:v0.14.1").
WithFile("/etc/stalwart/config.toml.orig", config). WithFile("/etc/stalwart/config.toml.orig", config).
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}). WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
WithDirectory("/tmp/stalwart", dataDir). WithDirectory("/tmp/stalwart", dataDir).
WithExposedPort(8080). // JMAP WithExposedPort(8080). // JMAP
WithExposedPort(1430). // IMAP WithExposedPort(1430). // IMAP
@@ -395,33 +440,91 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
Stdout(ctx) Stdout(ctx)
} }
// CheckMocks verifies that generated mocks are up to date. // FormatWrite formats Dart files and exports the modified /src directory.
// It snapshots the committed source (including any stale *.mocks.dart) before func (m *Ci) FormatWrite() *dagger.Directory {
// running build_runner, so git diff detects real staleness instead of always return m.setup(m.checkSrc()).
// comparing two freshly-generated outputs. WithExec([]string{"dart", "format", "lib", "test"}).
func (m *Ci) CheckMocks(ctx context.Context) (string, error) { Directory("/src")
return m.pubGetLayer(). }
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src"). // Analyze runs static analysis with dart analyze --fatal-infos.
WithExec([]string{"git", "init"}). func (m *Ci) Analyze(ctx context.Context) (string, error) {
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}). return m.setup(m.checkSrc()).
WithExec([]string{"git", "config", "user.name", "CI"}). WithExec([]string{"dart", "analyze", "--fatal-infos"}).
WithExec([]string{"git", "add", "."}).
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
Stdout(ctx) Stdout(ctx)
} }
// Coverage runs unit tests with coverage gate. // Codegen runs build_runner and exports the modified /src directory.
func (m *Ci) Codegen() *dagger.Directory {
return m.codegenBase().Directory("/src")
}
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
func (m *Ci) AnalyzeFix() *dagger.Directory {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "fix", "--apply"}).
Directory("/src")
}
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
var eg errgroup.Group
eg.Go(func() error {
_, err := m.CheckHygiene(ctx)
return err
})
eg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
eg.Go(func() error {
_, err := m.Format(ctx)
return err
})
eg.Go(func() error {
_, err := m.Analyze(ctx)
return err
})
eg.Go(func() error {
_, err := m.CheckGenerated(ctx)
return err
})
eg.Go(func() error {
_, err := m.Coverage(ctx)
return err
})
if err := eg.Wait(); err != nil {
return "", err
}
return "All fast checks passed!", nil
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It reuses the codegenBase() output instead of running build_runner a second time,
// diffing committed generated files against the freshly built ones.
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
fresh := m.codegenBase().Directory("/src")
return m.pubGetLayer().
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithExec([]string{"/bin/bash", "-c",
`stale=$(find /committed -name '*.g.dart' -o -name '*.mocks.dart' | ` +
`while IFS= read -r f; do rel="${f#/committed/}"; diff -q "$f" "/generated/$rel" >/dev/null 2>&1 || echo "$rel"; done); ` +
`if [ -n "$stale" ]; then ` +
`echo "ERROR: Generated files are out of date — run: dart run build_runner build"; echo "$stale"; exit 1; ` +
`else echo "Generated files are up to date."; fi`}).
Stdout(ctx)
}
// Coverage runs unit and widget tests with coverage gate.
func (m *Ci) Coverage(ctx context.Context) (string, error) { func (m *Ci) Coverage(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()). return m.setup(m.checkSrc()).
WithExec([]string{"/bin/bash", "-c", WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
WithExec([]string{"dart", "scripts/check_coverage.dart"}). WithExec([]string{"dart", "scripts/check_coverage.dart"}).
Stdout(ctx) Stdout(ctx)
@@ -432,7 +535,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())). return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c", WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx) Stdout(ctx)
} }
@@ -458,49 +561,77 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
Stdout(ctx) Stdout(ctx)
} }
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
// Check runs the full check suite. // Check runs the full check suite.
func (m *Ci) Check(ctx context.Context) (string, error) { func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel() defer cancel()
if _, err := m.CheckHygiene(ctx); err != nil { // Run cheap structural checks in parallel for faster fail detection.
return "Hygiene check failed", err var fastEg errgroup.Group
} fastEg.Go(func() error {
if _, err := m.CheckLayers(ctx); err != nil { _, err := m.CheckHygiene(ctx)
return "Layer check failed", err return err
})
fastEg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
if err := fastEg.Wait(); err != nil {
return "", err
} }
checkSetup := m.setup(m.checkSrc()) // Run format, analyze, generated-code check, and coverage in parallel —
// they all share the same setup base and have no dependencies on each other.
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil { var analyze, mocks, coverage string
return "Format check failed", err var checkEg errgroup.Group
} checkEg.Go(func() error {
setup := m.setup(m.checkSrc())
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx) _, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx)
if err != nil { return err
return analyze, err })
} checkEg.Go(func() error {
setup := m.setup(m.checkSrc())
mocks, err := m.CheckMocks(ctx) var err error
if err != nil { analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
return mocks, err return err
} })
checkEg.Go(func() error {
coverage, err := m.Coverage(ctx) var err error
if err != nil { mocks, err = m.CheckGenerated(ctx)
return coverage, err return err
})
checkEg.Go(func() error {
var err error
coverage, err = m.Coverage(ctx)
return err
})
if err := checkEg.Wait(); err != nil {
return "", err
} }
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
// sibling via context — which would surface as "context canceled" in dagger
// output and trigger spurious retries in check-dagger.
var testBackend, testIntegration string var testBackend, testIntegration string
eg, egCtx := errgroup.WithContext(ctx) var eg errgroup.Group
eg.Go(func() error { eg.Go(func() error {
var e error var e error
testBackend, e = m.TestBackend(egCtx) testBackend, e = m.TestBackend(ctx)
return e return e
}) })
eg.Go(func() error { eg.Go(func() error {
var e error var e error
testIntegration, e = m.TestIntegration(egCtx) testIntegration, e = m.TestIntegration(ctx)
return e return e
}) })
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
@@ -514,6 +645,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
func (m *Ci) GenerateBuildHistory( func (m *Ci) GenerateBuildHistory(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
) *dagger.Directory { ) *dagger.Directory {
@@ -525,7 +657,7 @@ func (m *Ci) GenerateBuildHistory(
From("python:3.12-alpine"). From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}). WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithExec([]string{"chmod", "700", "/root/.ssh"}). WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("SSH_USER", sshUser). WithEnvVariable("SSH_USER", sshUser).
WithEnvVariable("SSH_HOST", sshHost). WithEnvVariable("SSH_HOST", sshHost).
WithDirectory("/src", scriptSource). WithDirectory("/src", scriptSource).
@@ -538,18 +670,25 @@ func (m *Ci) GenerateBuildHistory(
func (m *Ci) BuildWebsite( func (m *Ci) BuildWebsite(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
// +optional
commitHash string,
) *dagger.Directory { ) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost) buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{ websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"website/"}, Include: []string{"website/"},
}).WithDirectory("website/content/builds", buildHistory) }).WithDirectory("website/content/builds", buildHistory)
return m.Hugo(). hugo := m.Hugo().
WithDirectory("/src", websiteSource). WithDirectory("/src", websiteSource).
WithWorkdir("/src/website"). WithWorkdir("/src/website")
if commitHash != "" {
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
}
return hugo.
WithExec([]string{"hugo", "--minify"}). WithExec([]string{"hugo", "--minify"}).
Directory("public") Directory("public")
} }
@@ -558,12 +697,15 @@ func (m *Ci) BuildWebsite(
func (m *Ci) PublishWebsite( func (m *Ci) PublishWebsite(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
// +optional
commitHash string,
) (string, error) { ) (string, error) {
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost) public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
return m.Deployer(sshKey). return m.Deployer(sshKey, knownHosts).
WithDirectory("/public", public). WithDirectory("/public", public).
WithExec([]string{"rsync", "-avz", "--delete", WithExec([]string{"rsync", "-avz", "--delete",
"--exclude=*.apk", "--exclude=*.tar.gz", "--exclude=*.apk", "--exclude=*.tar.gz",
@@ -579,9 +721,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
} }
// BuildLinuxRelease builds the Linux release bundle. // BuildLinuxRelease builds the Linux release bundle.
func (m *Ci) BuildLinuxRelease() *dagger.Directory { func (m *Ci) BuildLinuxRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.Directory {
args := []string{"flutter", "build", "linux", "--release"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setup(m.linuxSrc()). return m.setup(m.linuxSrc()).
WithExec([]string{"flutter", "build", "linux", "--release"}). WithExec(args).
Directory("build/linux/x64/release/bundle") Directory("build/linux/x64/release/bundle")
} }
@@ -589,36 +739,49 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory {
func (m *Ci) DeployLinux( func (m *Ci) DeployLinux(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
commitHash string, commitHash string,
) (string, error) { ) (string, error) {
bundle := m.BuildLinuxRelease() bundle := m.BuildLinuxRelease(commitHash)
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash) tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
return m.Deployer(sshKey). return m.Deployer(sshKey, knownHosts).
WithDirectory("/bundle", bundle). WithDirectory("/bundle", bundle).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}). WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}). WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}). WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
Stdout(ctx) Stdout(ctx)
} }
// setupKeystore decodes the base64 keystore into the android build container. // setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container { func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.setup(m.androidSrc()). return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64). WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword). WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`}) WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
} }
// BuildAndroidApk builds a release APK signed with the upload key. // BuildAndroidApk builds a release APK signed with the upload key.
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File { func (m *Ci) BuildAndroidApk(
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setupKeystore(keystoreBase64, keystorePassword). return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}). WithExec(args).
File("build/app/outputs/flutter-apk/app-release.apk") File("build/app/outputs/flutter-apk/app-release.apk")
} }
@@ -626,6 +789,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da
func (m *Ci) DeployApk( func (m *Ci) DeployApk(
ctx context.Context, ctx context.Context,
sshKey *dagger.Secret, sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
commitHash string, commitHash string,
@@ -633,24 +797,23 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
buildNumber string, buildNumber string,
) (string, error) { ) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber) apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
datePath := time.Now().Format("2006/01/02") datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash) apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
return m.Deployer(sshKey). return m.Deployer(sshKey, knownHosts).
WithFile("/tmp/app.apk", apk). WithFile("/tmp/app.apk", apk).
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}). WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}). WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
Stdout(ctx) Stdout(ctx)
} }
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab. // BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk. // Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory { func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.setup(m.firebaseSrc()). built := m.firebaseBase().
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}). WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android"). WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was // --no-daemon avoids connecting to a stale daemon whose registry file was
@@ -709,9 +872,17 @@ func (m *Ci) TestAndroidFirebase(
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it. // BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle. // versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease() *dagger.File { func (m *Ci) BuildAndroidRelease(
return m.setup(m.androidSrc()). // Git commit hash injected as GIT_HASH dart-define so the About page can display it.
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}). // +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
File("build/app/outputs/bundle/release/app-release.aab") File("build/app/outputs/bundle/release/app-release.aab")
} }
@@ -739,7 +910,7 @@ func (m *Ci) UploadToPlayStore(
From("python:3.12-alpine"). From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}). WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")). WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "requests", "google-auth"}). WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab). WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")). WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig). WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
@@ -783,14 +954,41 @@ func (m *Ci) PublishAndroid(
playStoreConfig *dagger.Secret, playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret, keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret, keystorePassword *dagger.Secret,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) (string, error) { ) (string, error) {
versionCode := int(time.Now().Unix()) versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease() aab := m.BuildAndroidRelease(commitHash)
stamped := m.StampAndroidVersionCode(aab, versionCode) stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword) signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig) return m.UploadToPlayStore(ctx, signed, playStoreConfig)
} }
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
return dag.Container().
From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
// Graph returns a Mermaid diagram of the CI pipeline structure. // Graph returns a Mermaid diagram of the CI pipeline structure.
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live) // Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
// or save it as a .md file to get a rendered diagram. // or save it as a .md file to get a rendered diagram.
@@ -799,12 +997,12 @@ func (m *Ci) PublishAndroid(
// //
// dagger call --progress=plain -q -m ci --source=. graph // dagger call --progress=plain -q -m ci --source=. graph
func (m *Ci) Graph() string { func (m *Ci) Graph() string {
return `# CI Pipeline Graph return fmt.Sprintf(`# CI Pipeline Graph
` + "```" + `mermaid `+"```"+`mermaid
flowchart TD flowchart TD
subgraph dagger ["Dagger · Check pipeline"] subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"] toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
pubGet["pubGetLayer\nflutter pub get"] pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"] codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
@@ -814,7 +1012,7 @@ flowchart TD
pubGet --> hygiene["CheckHygiene"] pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"] pubGet --> layers["CheckLayers"]
pubGet --> mocks["CheckMocks\n(own build_runner run)"] pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
codegen --> fmt["Format"] codegen --> fmt["Format"]
codegen --> analyze["Analyze"] codegen --> analyze["Analyze"]
@@ -835,16 +1033,25 @@ flowchart TD
integration --> check integration --> check
end end
subgraph forgejo ["Codeberg CI · .forgejo/workflows/ci.yml"] subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
ciCheck["check"] ciCheck["check"]
buildLinux["build-linux\n(main only)"] end
deployPS["deploy-playstore\n(main only)"]
pubWeb["publish-website\n(main only)"]
ciCheck --> buildLinux subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
ciCheck --> deployPS detectChanges["check-changes\ndetect android / linux diff"]
buildLinux["build-linux\n(linux changed)"]
deployPS["deploy-playstore\n(android changed)"]
deployApk["deploy-apk\n(android changed)"]
fbTest["test-android-firebase\n(android changed)"]
pubWeb["publish-website\n(any build succeeded)"]
detectChanges --> buildLinux
detectChanges --> deployPS
detectChanges --> deployApk
detectChanges --> fbTest
buildLinux --> pubWeb buildLinux --> pubWeb
deployPS --> pubWeb deployPS --> pubWeb
deployApk --> pubWeb
end end
check -- "task check-dagger" --> ciCheck check -- "task check-dagger" --> ciCheck
+2 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
REPO_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load .env into environment # Load .env into environment
@@ -13,7 +14,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
# Add nix profile and nix store tools (task, dagger) to PATH # Add nix profile and nix store tools (task, dagger) to PATH
export PATH="$HOME/.nix-profile/bin:$PATH" export PATH="$HOME/.nix-profile/bin:$PATH"
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1) bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH" [ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
done done
+22
View File
@@ -4,6 +4,28 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26)
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
dependencies up to date. All required components are in main:
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
- `ci/main.go``Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
- `Taskfile.yml``renovate` task
- Issue #257 closed.
## Tasks (2026-05-11) ## Tasks (2026-05-11)
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering - **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
+26 -3
View File
@@ -48,11 +48,28 @@
chmod +x $out/bin/fgj chmod +x $out/bin/fgj
''; '';
}; };
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
dagger021 = pkgs.stdenv.mkDerivation {
pname = "dagger";
version = "0.21.4";
src = pkgs.fetchurl {
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
};
sourceRoot = ".";
installPhase = ''
mkdir -p $out/bin
cp dagger $out/bin/dagger
chmod +x $out/bin/dagger
'';
};
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Dagger CLI # Dagger CLI
dagger.packages.${system}.dagger dagger021
# Go compiler — for Dagger development # Go compiler — for Dagger development
go go
@@ -94,16 +111,22 @@
sqlite sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py) # python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [ (python3.withPackages (ps: with ps; [
google-auth google-api-python-client
requests google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py ])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub) fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
librsvg # rsvg-convert — SVG→PNG for generate-icons task
]); ]);
shellHook = '' shellHook = ''
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI # nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
export IN_NIX_SHELL=1 export IN_NIX_SHELL=1
# Point Dagger client at the running engine socket
export DAGGER_HOST=unix:///run/dagger/engine.sock
# Disable Flutter telemetry inside dev shell # Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true export FLUTTER_SUPPRESS_ANALYTICS=true
+3
View File
@@ -0,0 +1,3 @@
module codeberg.org/guettli/sharedinbox
go 1.22
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
<!-- White Background -->
<rect width="512" height="512" fill="white"/>
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
<!-- Red -->
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
<!-- Orange -->
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
<!-- Yellow -->
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
<!-- Green -->
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
<!-- Blue -->
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
<!-- Purple -->
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -2
View File
@@ -317,7 +317,7 @@ void main() {
// ── Check Sent folder ────────────────────────────────────────────────── // ── Check Sent folder ──────────────────────────────────────────────────
// Use the drawer to switch folders (no back button on Linux desktop). // Use the drawer to switch folders (no back button on Linux desktop).
await tester.tap(find.byTooltip('Open navigation menu')); await tester.tap(find.byTooltip('Open folders'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Sent')); await tester.tap(find.text('Sent'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -331,7 +331,7 @@ void main() {
expect(find.text(subject), findsOneWidget); expect(find.text(subject), findsOneWidget);
// ── Check Inbox ──────────────────────────────────────────────────────── // ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open navigation menu')); await tester.tap(find.byTooltip('Open folders'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+1
View File
@@ -0,0 +1 @@
const int dbSchemaVersion = 41;
+88
View File
@@ -0,0 +1,88 @@
enum FilterField {
from_,
to,
cc,
subject,
size;
String get label => switch (this) {
FilterField.from_ => 'From',
FilterField.to => 'To',
FilterField.cc => 'CC',
FilterField.subject => 'Subject',
FilterField.size => 'Size (bytes)',
};
List<FilterComparison> get allowedComparisons => switch (this) {
FilterField.size => [FilterComparison.over, FilterComparison.under],
_ => [
FilterComparison.contains,
FilterComparison.is_,
FilterComparison.matches,
],
};
}
enum FilterComparison {
contains,
is_,
matches,
over,
under;
String get label => switch (this) {
FilterComparison.contains => 'contains',
FilterComparison.is_ => 'is',
FilterComparison.matches => 'matches',
FilterComparison.over => 'over',
FilterComparison.under => 'under',
};
}
enum FilterOperator { and_, or_ }
sealed class FilterNode {}
final class FilterLeaf extends FilterNode {
FilterLeaf({
required this.field,
required this.comparison,
required this.value,
});
final FilterField field;
final FilterComparison comparison;
final String value;
FilterLeaf copyWith({
FilterField? field,
FilterComparison? comparison,
String? value,
}) =>
FilterLeaf(
field: field ?? this.field,
comparison: comparison ?? this.comparison,
value: value ?? this.value,
);
}
final class FilterGroup extends FilterNode {
FilterGroup({required this.operator, required this.children});
final FilterOperator operator;
final List<FilterNode> children;
bool get isEmpty => children.isEmpty;
FilterGroup copyWith({
FilterOperator? operator,
List<FilterNode>? children,
}) =>
FilterGroup(
operator: operator ?? this.operator,
children: children ?? this.children,
);
static FilterGroup empty() =>
FilterGroup(operator: FilterOperator.and_, children: []);
}
+358
View File
@@ -0,0 +1,358 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
/// suitable for display in the visual filter editor.
///
/// Returns null if the script uses features outside the supported subset.
class FilterSieveConverter {
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
try {
final s = _Sc(script);
s.skip();
if (s.peekWord() == 'require') {
s.readWord();
s.skip();
_parseStringOrList(s);
s.skip();
s.expectChar(';');
s.skip();
}
if (s.peekWord() != 'if') return null;
s.readWord();
s.skip();
final node = _parseTest(s);
if (node == null) return null;
s.skip();
s.expectChar('{');
s.skip();
final actions = <SieveAction>[];
while (s.peek() != '}' && !s.isAtEnd) {
final action = _parseAction(s);
if (action == null) return null;
actions.add(action);
s.skip();
}
s.expectChar('}');
final group = switch (node) {
final FilterGroup g => g,
final FilterLeaf l =>
FilterGroup(operator: FilterOperator.and_, children: [l]),
};
return (group: group, actions: actions);
} catch (_) {
return null;
}
}
FilterNode? _parseTest(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'allof' || word == 'anyof') {
s.readWord();
s.skip();
s.expectChar('(');
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
final children = <FilterNode>[];
while (true) {
s.skip();
if (s.peek() == ')') break;
final child = _parseTest(s);
if (child == null) return null;
children.add(child);
s.skip();
if (s.peek() == ',') s.advance();
}
s.expectChar(')');
return FilterGroup(operator: op, children: children);
}
return _parseSingleTest(s);
}
FilterLeaf? _parseSingleTest(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'address') {
s.readWord();
s.skip();
final matchType = s.readTaggedArg();
s.skip();
final headers = _parseStringOrList(s);
s.skip();
final values = _parseStringOrList(s);
final field = switch (headers.firstOrNull?.toLowerCase()) {
'from' => FilterField.from_,
'to' => FilterField.to,
'cc' => FilterField.cc,
_ => null,
};
if (field == null) return null;
final comp = _comp(matchType);
if (comp == null) return null;
return FilterLeaf(
field: field,
comparison: comp,
value: values.firstOrNull ?? '',
);
}
if (word == 'header') {
s.readWord();
s.skip();
final matchType = s.readTaggedArg();
s.skip();
final headers = _parseStringOrList(s);
s.skip();
final values = _parseStringOrList(s);
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
final comp = _comp(matchType);
if (comp == null) return null;
return FilterLeaf(
field: FilterField.subject,
comparison: comp,
value: values.firstOrNull ?? '',
);
}
if (word == 'size') {
s.readWord();
s.skip();
final compTag = s.readTaggedArg();
s.skip();
final numStr = s.readDigits();
final comp = switch (compTag.toLowerCase()) {
':over' => FilterComparison.over,
':under' => FilterComparison.under,
_ => null,
};
if (comp == null) return null;
return FilterLeaf(
field: FilterField.size,
comparison: comp,
value: numStr,
);
}
return null;
}
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
':contains' => FilterComparison.contains,
':is' => FilterComparison.is_,
':matches' => FilterComparison.matches,
_ => null,
};
SieveAction? _parseAction(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'fileinto') {
s.readWord();
s.skip();
final folder = _parseString(s);
s.skip();
s.expectChar(';');
return FileIntoAction(folder);
}
if (word == 'keep') {
s.readWord();
s.skip();
s.expectChar(';');
return KeepAction();
}
if (word == 'discard') {
s.readWord();
s.skip();
s.expectChar(';');
return DiscardAction();
}
if (word == 'setflag' || word == 'addflag') {
s.readWord();
s.skip();
final flags = _parseStringOrList(s);
s.skip();
s.expectChar(';');
if (flags.any(
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
)) {
return MarkAsSeenAction();
}
return FlagAction(flags);
}
return null;
}
List<String> _parseStringOrList(_Sc s) {
s.skip();
if (s.peek() == '[') {
s.advance();
final items = <String>[];
while (true) {
s.skip();
if (s.peek() == ']') {
s.advance();
break;
}
items.add(_parseString(s));
s.skip();
if (s.peek() == ',') s.advance();
}
return items;
}
return [_parseString(s)];
}
String _parseString(_Sc s) {
s.skip();
return s.readQuotedString();
}
}
// Minimal scanner for the supported Sieve subset.
class _Sc {
_Sc(this._src);
final String _src;
int _pos = 0;
bool get isAtEnd => _pos >= _src.length;
String? peek() => isAtEnd ? null : _src[_pos];
String advance() {
if (isAtEnd) throw _ScanErr('Unexpected end');
return _src[_pos++];
}
void skip() {
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
_pos++;
} else if (ch == '#') {
while (!isAtEnd && _src[_pos] != '\n') {
_pos++;
}
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
_pos += 2;
while (_pos + 1 < _src.length) {
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
_pos += 2;
break;
}
_pos++;
}
} else {
break;
}
}
}
String? peekWord() {
if (isAtEnd) return null;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) return ch;
if (ch == ':') {
var end = _pos + 1;
while (end < _src.length && _wc(_src[end])) {
end++;
}
return _src.substring(_pos, end).toLowerCase();
}
if (_wc(ch)) {
var end = _pos + 1;
while (end < _src.length && _wc(_src[end])) {
end++;
}
return _src.substring(_pos, end).toLowerCase();
}
return null;
}
String readWord() {
final start = _pos;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) {
_pos++;
return ch;
}
if (ch == ':') {
_pos++;
while (!isAtEnd && _wc(_src[_pos])) {
_pos++;
}
} else {
while (!isAtEnd && _wc(_src[_pos])) {
_pos++;
}
}
return _src.substring(start, _pos).toLowerCase();
}
String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord();
throw _ScanErr('Expected tagged arg at $_pos');
}
String readDigits() {
final start = _pos;
while (!isAtEnd && _dig(_src[_pos])) {
_pos++;
}
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
return _src.substring(start, _pos);
}
String readQuotedString() {
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
_pos++;
final buf = StringBuffer();
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == '"') {
_pos++;
return buf.toString();
}
if (ch == '\\' && _pos + 1 < _src.length) {
_pos++;
buf.write(_src[_pos]);
_pos++;
} else {
buf.write(ch);
_pos++;
}
}
throw _ScanErr('Unterminated string');
}
void expectChar(String ch) {
skip();
if (isAtEnd || _src[_pos] != ch) {
throw _ScanErr(
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
);
}
_pos++;
}
static bool _wc(String ch) {
final c = ch.codeUnitAt(0);
return (c >= 0x41 && c <= 0x5A) ||
(c >= 0x61 && c <= 0x7A) ||
(c >= 0x30 && c <= 0x39) ||
c == 0x5F ||
c == 0x2D;
}
static bool _dig(String ch) {
final c = ch.codeUnitAt(0);
return c >= 0x30 && c <= 0x39;
}
}
class _ScanErr implements Exception {
_ScanErr(this.message);
final String message;
}
+16
View File
@@ -192,6 +192,22 @@ class EmailThread {
required this.accountId, required this.accountId,
required this.mailboxPath, required this.mailboxPath,
}); });
/// Wraps a single [Email] as a one-message thread for uniform rendering.
factory EmailThread.fromEmail(Email e) => EmailThread(
threadId: e.threadId ?? e.id,
subject: e.subject,
participants: e.from,
latestDate: e.sentAt ?? e.receivedAt,
messageCount: 1,
hasUnread: !e.isSeen,
isFlagged: e.isFlagged,
latestEmailId: e.id,
preview: e.preview,
emailIds: [e.id],
accountId: e.accountId,
mailboxPath: e.mailboxPath,
);
} }
class EmailAddress { class EmailAddress {
+17
View File
@@ -0,0 +1,17 @@
class EmailNote {
final String id; // UUID (X-SharedInbox-Note-Id)
final String accountId;
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
final String noteText;
final String serverId; // IMAP UID (as string) or JMAP email ID
final DateTime createdAt;
const EmailNote({
required this.id,
required this.accountId,
required this.messageId,
required this.noteText,
required this.serverId,
required this.createdAt,
});
}
+31
View File
@@ -0,0 +1,31 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
enum PrefetchMode {
disabled,
wifiOnly,
always;
static PrefetchMode fromString(String? value) {
return PrefetchMode.values.firstWhere(
(e) => e.name == value,
orElse: () => PrefetchMode.wifiOnly,
);
}
}
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
this.prefetchMode = PrefetchMode.wifiOnly,
this.bodyCacheLimitMb = 100,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
final PrefetchMode prefetchMode;
final int bodyCacheLimitMb;
}
+12 -1
View File
@@ -1,3 +1,4 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository { abstract class EmailRepository {
@@ -15,6 +16,10 @@ abstract class EmailRepository {
int limit = 50, int limit = 50,
}); });
/// Returns threads from the INBOX mailbox of every account, sorted by latest
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
/// Returns all emails belonging to [threadId] in [mailboxPath]. /// Returns all emails belonging to [threadId] in [mailboxPath].
Stream<List<Email>> observeEmailsInThread( Stream<List<Email>> observeEmailsInThread(
String accountId, String accountId,
@@ -54,9 +59,15 @@ abstract class EmailRepository {
); );
/// Searches the local DB across all mailboxes of [accountId] (or all accounts /// Searches the local DB across all mailboxes of [accountId] (or all accounts
/// if null) by subject and preview. Fast, works offline. /// if null) by subject, preview, and notes. Fast, works offline.
Future<List<Email>> searchEmailsGlobal(String? accountId, String query); Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
);
/// Returns all locally cached emails in any mailbox of [accountId] (or all /// Returns all locally cached emails in any mailbox of [accountId] (or all
/// accounts if null) whose from, to, or cc fields contain [address]. /// accounts if null) whose from, to, or cc fields contain [address].
Future<List<Email>> getEmailsByAddress(String? accountId, String address); Future<List<Email>> getEmailsByAddress(String? accountId, String address);
@@ -11,4 +11,17 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId]. /// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId); Future<void> clearForResync(String accountId);
/// Creates a new mailbox named [name] for [accountId] and tags it with
/// [role] in the local database. For JMAP accounts the role is also sent
/// to the server. Returns the newly created [Mailbox].
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
);
/// Creates a new mailbox named [name] for [accountId] without a special role.
/// Returns the newly created [Mailbox].
Future<Mailbox> createMailbox(String accountId, String name);
} }
@@ -0,0 +1,15 @@
import 'package:sharedinbox/core/models/note.dart';
abstract class NoteRepository {
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
/// Fetches notes from the server into the local cache.
Future<void> syncNotes(String accountId, String messageId);
/// Creates a new note on the server and caches it locally.
Future<void> addNote(String accountId, String messageId, String text);
/// Deletes a note from the server and removes it from the local cache.
Future<void> deleteNote(String noteId);
}
@@ -19,6 +19,8 @@ class SyncLogEntry {
required this.id, required this.id,
required this.result, required this.result,
this.errorMessage, this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol, required this.protocol,
required this.emailsFetched, required this.emailsFetched,
required this.emailsSkipped, required this.emailsSkipped,
@@ -34,6 +36,8 @@ class SyncLogEntry {
final int id; final int id;
final String result; // 'ok' or 'error' final String result; // 'ok' or 'error'
final String? errorMessage; final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap' final String protocol; // 'imap' or 'jmap'
final int emailsFetched; final int emailsFetched;
final int emailsSkipped; final int emailsSkipped;
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -0,0 +1,14 @@
import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Future<void> updatePrefetchMode(PrefetchMode mode);
Future<void> updateBodyCacheLimitMb(int mb);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
Future<void> removeTrustedImageSender(String senderEmail);
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
/// Prefetches email bodies in the background and enforces a local cache size
/// limit by evicting the oldest cached bodies when the limit is exceeded.
class BodyCacheService {
BodyCacheService(this._db, this._accountRepo);
final AppDatabase _db;
final AccountRepository _accountRepo;
static const _batchSize = 20;
Future<void> run() async {
final prefs = await (_db.select(
_db.userPreferences,
)).getSingleOrNull();
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
final limitBytes = limitMb * 1024 * 1024;
await _evictIfNeeded(limitBytes);
final candidates = await _fetchCandidates();
if (candidates.isEmpty) return;
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
for (final emailId in candidates) {
final currentSize = await _getCacheSizeBytes();
if (currentSize >= limitBytes) break;
try {
await emailRepo.getEmailBody(emailId);
} catch (_) {
// Skip emails that fail to fetch.
}
}
}
Future<void> _evictIfNeeded(int limitBytes) async {
final currentSize = await _getCacheSizeBytes();
if (currentSize <= limitBytes) return;
final bodies = await (_db.select(_db.emailBodies)
..where((t) => t.cachedAt.isNotNull())
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
.get();
var remaining = currentSize;
for (final body in bodies) {
if (remaining <= limitBytes) break;
final bodySize =
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
await (_db.delete(_db.emailBodies)
..where((t) => t.emailId.equals(body.emailId)))
.go();
remaining -= bodySize;
}
}
Future<int> _getCacheSizeBytes() async {
final result = await _db
.customSelect(
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
)
.getSingle();
return result.read<int>('total');
}
Future<List<String>> _fetchCandidates() async {
final rows = await _db.customSelect(
'SELECT e.id FROM emails e '
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
'WHERE eb.email_id IS NULL '
'ORDER BY e.received_at DESC '
'LIMIT ?',
variables: [Variable.withInt(_batchSize)],
).get();
return rows.map((r) => r.read<String>('id')).toList();
}
}
@@ -92,8 +92,9 @@ class ShareEncryptionService {
) { ) {
if (!s.startsWith(_pubKeyPrefix)) return null; if (!s.startsWith(_pubKeyPrefix)) return null;
try { try {
final data = final data = Uint8List.fromList(
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length))); base64.decode(s.substring(_pubKeyPrefix.length)),
);
if (data.length != _keyIdLen + _pubKeyLen) return null; if (data.length != _keyIdLen + _pubKeyLen) return null;
return ( return (
keyId: data.sublist(0, _keyIdLen), keyId: data.sublist(0, _keyIdLen),
+2 -7
View File
@@ -1,6 +1,7 @@
import 'package:sharedinbox/core/sieve/sieve_actions.dart'; import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_rule.dart'; import 'package:sharedinbox/core/sieve/sieve_rule.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
/// A lightweight email representation used by [SieveInterpreter]. /// A lightweight email representation used by [SieveInterpreter].
/// Header names are lower-cased. /// Header names are lower-cased.
@@ -102,17 +103,11 @@ class SieveInterpreter {
return switch (matchType) { return switch (matchType) {
':contains' => k.isEmpty || v.contains(k), ':contains' => k.isEmpty || v.contains(k),
':is' => v == k, ':is' => v == k,
':matches' => _globMatch(v, k), ':matches' => globMatch(v, k),
_ => false, _ => false,
}; };
} }
bool _globMatch(String value, String pattern) {
final regexStr =
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value);
}
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) { void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
for (final action in actions) { for (final action in actions) {
switch (action) { switch (action) {
+3 -9
View File
@@ -466,9 +466,7 @@ class _Scanner {
String readTaggedArg() { String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord(); if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException( throw SieveParseException('Expected tagged argument at position $_pos');
'Expected tagged argument at position $_pos',
);
} }
String? peekSizeUnit() { String? peekSizeUnit() {
@@ -480,9 +478,7 @@ class _Scanner {
String readDigits() { String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) { if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException( throw SieveParseException('Expected number at position $_pos');
'Expected number at position $_pos',
);
} }
final start = _pos; final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) { while (!isAtEnd && _isDigit(_src[_pos])) {
@@ -493,9 +489,7 @@ class _Scanner {
String readQuotedString() { String readQuotedString() {
if (_src[_pos] != '"') { if (_src[_pos] != '"') {
throw SieveParseException( throw SieveParseException('Expected " at position $_pos');
'Expected " at position $_pos',
);
} }
_pos++; // skip opening quote _pos++; // skip opening quote
final buf = StringBuffer(); final buf = StringBuffer();
+100
View File
@@ -0,0 +1,100 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
/// (RFC 5228 subset).
class SieveSerializer {
String serialize(FilterGroup filter, List<SieveAction> actions) {
final buf = StringBuffer();
final requires = _collectRequires(actions);
if (requires.isNotEmpty) {
buf.writeln(
'require [${requires.map((r) => '"$r"').join(', ')}];',
);
}
if (filter.isEmpty) {
for (final a in actions) {
buf.writeln(_serializeAction(a));
}
return buf.toString();
}
buf.write('if ');
buf.write(_serializeNode(filter));
buf.writeln(' {');
for (final a in actions) {
buf.writeln(' ${_serializeAction(a)}');
}
buf.writeln('}');
return buf.toString();
}
List<String> _collectRequires(List<SieveAction> actions) {
final req = <String>[];
for (final a in actions) {
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
if ((a is FlagAction || a is MarkAsSeenAction) &&
!req.contains('imap4flags')) {
req.add('imap4flags');
}
}
return req;
}
String _serializeNode(FilterNode node) => switch (node) {
final FilterLeaf leaf => _serializeLeaf(leaf),
final FilterGroup group => _serializeGroup(group),
};
String _serializeGroup(FilterGroup group) {
if (group.isEmpty) return 'true';
if (group.children.length == 1) return _serializeNode(group.children.first);
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
final parts = group.children.map(_serializeNode).join(',\n ');
return '$op(\n $parts\n)';
}
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
FilterField.from_ ||
FilterField.to ||
FilterField.cc =>
_serializeAddressLeaf(leaf),
FilterField.subject => _serializeHeaderLeaf(leaf),
FilterField.size => _serializeSizeLeaf(leaf),
};
String _serializeAddressLeaf(FilterLeaf leaf) {
final header = switch (leaf.field) {
FilterField.from_ => 'from',
FilterField.to => 'to',
FilterField.cc => 'cc',
_ => throw StateError('not an address field'),
};
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
}
String _serializeHeaderLeaf(FilterLeaf leaf) =>
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
String _serializeSizeLeaf(FilterLeaf leaf) {
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
return 'size $comp ${leaf.value}';
}
String _matchType(FilterComparison comp) => switch (comp) {
FilterComparison.contains => ':contains',
FilterComparison.is_ => ':is',
FilterComparison.matches => ':matches',
_ => ':contains',
};
String _serializeAction(SieveAction action) => switch (action) {
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
KeepAction() => 'keep;',
DiscardAction() => 'discard;',
MarkAsSeenAction() => r'setflag "\\Seen";',
final FlagAction a =>
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
};
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
}
+7
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult; import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -259,6 +260,8 @@ class _AccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
@@ -294,6 +297,7 @@ class _AccountSync implements _SyncLoop {
bool _isPermanentError(Object e) { bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true; if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase(); final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings. // enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') || return s.contains('invalid credentials') ||
@@ -511,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap', protocol: 'jmap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
@@ -546,6 +552,7 @@ class _JmapAccountSync implements _SyncLoop {
bool _isPermanentError(Object e) { bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true; if (isTlsConfigError(e)) return true;
if (e is MissingPluginException) return true;
final s = e.toString().toLowerCase(); final s = e.toString().toLowerCase();
return s.contains('invalid credentials') || return s.contains('invalid credentials') ||
s.contains('authentication failed') || s.contains('authentication failed') ||
+54 -2
View File
@@ -6,11 +6,14 @@ import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/services/body_cache_service.dart';
import 'package:sharedinbox/core/services/notification_service.dart'; import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
@@ -20,13 +23,21 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
const _kTaskName = 'si_bg_sync'; const _kTaskName = 'si_bg_sync';
const _kPrefetchTaskName = 'si_bg_prefetch';
const _kResourceType = 'background_check'; const _kResourceType = 'background_check';
@pragma('vm:entry-point') @pragma('vm:entry-point')
void callbackDispatcher() { void callbackDispatcher() {
Workmanager().executeTask((_, __) async { // Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((taskName, __) async {
try { try {
await _doBackgroundSync(); if (taskName == _kPrefetchTaskName) {
await _doBodyPrefetch();
} else {
await _doBackgroundSync();
}
} catch (_) {} } catch (_) {}
return true; return true;
}); });
@@ -51,6 +62,31 @@ Future<void> registerBackgroundSync() async {
} }
} }
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
/// Call on app startup and whenever the user changes the prefetch preference.
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
try {
if (mode == PrefetchMode.disabled) {
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
return;
}
final networkType = mode == PrefetchMode.wifiOnly
? NetworkType.unmetered
: NetworkType.connected;
await Workmanager().registerPeriodicTask(
_kPrefetchTaskName,
_kPrefetchTaskName,
frequency: const Duration(hours: 1),
constraints: Constraints(networkType: networkType),
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
);
} on PlatformException {
// Ignore — WorkManager unavailable.
} on MissingPluginException {
// Ignore — plugin not registered.
} catch (_) {}
}
Future<void> _doBackgroundSync() async { Future<void> _doBackgroundSync() async {
final dir = await getApplicationSupportDirectory(); final dir = await getApplicationSupportDirectory();
final db = AppDatabase( final db = AppDatabase(
@@ -72,6 +108,22 @@ Future<void> _doBackgroundSync() async {
} }
} }
Future<void> _doBodyPrefetch() async {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
);
try {
final accountRepo = AccountRepositoryImpl(
db,
const FlutterSecureStorageImpl(),
);
await BodyCacheService(db, accountRepo).run();
} finally {
await db.close();
}
}
Future<void> _checkAccount( Future<void> _checkAccount(
AppDatabase db, AppDatabase db,
AccountRepository accountRepo, AccountRepository accountRepo,
+1 -4
View File
@@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"') .replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
.replaceAll("src='cid:$bareCid'", "src='$dataUri'") .replaceAll("src='cid:$bareCid'", "src='$dataUri'")
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"') .replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
.replaceAll( .replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
"src='cid:${bareCid.toLowerCase()}'",
"src='$dataUri'",
);
} }
return result; return result;
} }
+9
View File
@@ -0,0 +1,9 @@
/// Returns true if [value] matches the glob [pattern].
///
/// Supports `*` (any number of characters) and `?` (exactly one character).
/// The comparison is case-insensitive, which is appropriate for email addresses.
bool globMatch(String value, String pattern) {
final regexStr =
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value);
}
+311 -12
View File
@@ -6,6 +6,8 @@ import 'package:drift/native.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
import 'package:sqlite3/sqlite3.dart' show Database;
part 'database.g.dart'; part 'database.g.dart';
@@ -192,6 +194,9 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true. // Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()(); TextColumn get protocolLog => text().nullable()();
// Added in schema v33: stack trace and permanent flag for error entries.
TextColumn get errorStackTrace => text().nullable()();
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
} }
/// Per-mailbox breakdown for a single sync cycle. /// Per-mailbox breakdown for a single sync cycle.
@@ -303,6 +308,71 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId}; Set<Column> get primaryKey => {accountId, messageId};
} }
/// Senders for whom remote images are loaded automatically.
/// Per-device/per-user — not tied to any email account.
@DataClassName('ImageTrustedSenderRow')
class ImageTrustedSenders extends Table {
TextColumn get senderEmail => text()();
DateTimeColumn get addedAt => dateTime()();
@override
Set<Column> get primaryKey => {senderEmail};
}
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
// Added in schema v39.
@DataClassName('EmailNoteRow')
class EmailNotes extends Table {
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
TextColumn get id => text()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
TextColumn get messageId => text()();
TextColumn get noteText => text()();
// IMAP UID (as string) or JMAP email ID of the note message on the server.
TextColumn get serverId => text()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// Records the first time the user ran each app version (identified by GIT_HASH).
/// Added in schema v40.
@DataClassName('InstalledVersionRow')
class InstalledVersions extends Table {
TextColumn get gitHash => text()();
DateTimeColumn get installedAt => dateTime()();
@override
Set<Column> get primaryKey => {gitHash};
}
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
IntColumn get id => integer()();
// 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
TextColumn get prefetchMode =>
text().withDefault(const Constant('wifiOnly'))();
// Added in schema v38: max cache size for offline email bodies, in megabytes.
IntColumn get bodyCacheLimitMb =>
integer().withDefault(const Constant(100))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ────────────────────────────────────────────────────────────────── // ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase( @DriftDatabase(
@@ -323,13 +393,17 @@ class LocalSieveApplied extends Table {
LocalSieveScripts, LocalSieveScripts,
LocalSieveApplied, LocalSieveApplied,
ShareKeys, ShareKeys,
UserPreferences,
ImageTrustedSenders,
EmailNotes,
InstalledVersions,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 32; int get schemaVersion => dbSchemaVersion;
Future<void> _createEmailFts() async { Future<void> _createEmailFts() async {
await customStatement(''' await customStatement('''
@@ -570,8 +644,172 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) { if (from < 32) {
await m.createTable(localSieveApplied); await m.createTable(localSieveApplied);
} }
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
if (from < 34) {
await m.createTable(userPreferences);
}
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
if (from < 37) {
await m.createTable(imageTrustedSenders);
}
if (from >= 34 && from < 38) {
await m.addColumn(userPreferences, userPreferences.prefetchMode);
await m.addColumn(
userPreferences,
userPreferences.bodyCacheLimitMb,
);
}
if (from < 39) {
await m.createTable(emailNotes);
}
if (from < 40) {
await m.createTable(installedVersions);
}
if (from < 41) {
// Fix IMAP email IDs to include mailboxPath, preventing UID
// collisions across mailboxes (IMAP UIDs are mailbox-scoped).
// New format: "accountId:mailboxPath:uid" (was "accountId:uid").
//
// defer_foreign_keys defers the email_bodies→emails FK check
// to COMMIT so the two tables can be updated sequentially inside
// the migration transaction without a transient FK violation.
await customStatement('PRAGMA defer_foreign_keys = ON');
// 1. Remap email_bodies.email_id before emails.id changes.
await customStatement('''
UPDATE email_bodies
SET email_id = (
SELECT e.account_id || ':' || e.mailbox_path || ':' || CAST(e.uid AS TEXT)
FROM emails e
JOIN accounts a ON a.id = e.account_id
WHERE e.id = email_bodies.email_id
AND a.account_type = 'imap'
)
WHERE EXISTS (
SELECT 1 FROM emails e
JOIN accounts a ON a.id = e.account_id
WHERE e.id = email_bodies.email_id
AND a.account_type = 'imap'
)
''');
// 2. Update emails.thread_id where it was set to the email's own
// id (fallback for messages with no Message-ID header).
await customStatement('''
UPDATE emails
SET thread_id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
AND thread_id = id
''');
// 3. Update the primary key on emails.
await customStatement('''
UPDATE emails
SET id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
WHERE account_id IN (
SELECT id FROM accounts WHERE account_type = 'imap'
)
''');
// 5. Rebuild threads for IMAP accounts from the updated email rows.
// The threads table stores denormalised data (latest_email_id,
// email_ids_json) that references email IDs, so it is simpler to
// delete and reconstruct than to patch the JSON in SQL.
await customStatement('''
DELETE FROM threads
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
''');
final imapAccounts = await (select(accounts)
..where((t) => t.accountType.equals('imap')))
.get();
for (final acct in imapAccounts) {
final emailRows = await (select(emails)
..where((t) => t.accountId.equals(acct.id)))
.get();
final groups = <String, List<Email>>{};
for (final row in emailRows) {
final key = '${row.mailboxPath}:${row.threadId ?? row.id}';
groups.putIfAbsent(key, () => []).add(row);
}
for (final threadEmails in groups.values) {
threadEmails.sort((a, b) {
final da = a.sentAt ?? a.receivedAt;
final db = b.sentAt ?? b.receivedAt;
return da.compareTo(db);
});
final latest = threadEmails.last;
final seen = <String>{};
final participants = <Map<String, dynamic>>[];
for (final e in threadEmails) {
final from = jsonDecode(e.fromJson) as List<dynamic>;
for (final a in from.cast<Map<String, dynamic>>()) {
final email = a['email'] as String;
if (seen.add(email)) {
participants.add({'name': a['name'], 'email': email});
}
}
}
await into(threads).insert(
ThreadsCompanion.insert(
id: latest.threadId ?? latest.id,
accountId: latest.accountId,
mailboxPath: latest.mailboxPath,
subject: Value(latest.subject),
latestDate: latest.sentAt ?? latest.receivedAt,
messageCount: Value(threadEmails.length),
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
participantsJson: Value(jsonEncode(participants)),
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
),
);
}
}
}
}, },
); );
/// Inserts a row for [gitHash] the first time that version is seen.
/// Subsequent calls for the same hash are silently ignored so the original
/// install timestamp is preserved.
Future<void> recordInstalledVersionIfNew(String gitHash) async {
if (gitHash.isEmpty) return;
await into(installedVersions).insert(
InstalledVersionsCompanion.insert(
gitHash: gitHash,
installedAt: DateTime.now(),
),
mode: InsertMode.insertOrIgnore,
);
}
Future<Map<String, DateTime>> loadInstalledVersions() async {
final rows = await select(installedVersions).get();
return {for (final r in rows) r.gitHash: r.installedAt};
}
} }
// Resolved once in main() via initDatabasePath() before runApp(). // Resolved once in main() via initDatabasePath() before runApp().
@@ -609,6 +847,17 @@ Future<String> _resolveDatabasePath() async {
await Future<void>.delayed(Duration(milliseconds: ms)); await Future<void>.delayed(Duration(milliseconds: ms));
} }
} }
// On Android, path_provider can be permanently broken on some devices
// regardless of how long we wait (issue #192). Derive the path from
// /proc/self/cmdline (the Android process name == package name) without
// a platform channel as a last resort so the app can still open its DB.
if (Platform.isAndroid) {
final fallback = await _androidFallbackPath();
if (fallback != null) {
_dbPath = fallback;
return _dbPath!;
}
}
throw PlatformException( throw PlatformException(
code: 'channel-error', code: 'channel-error',
message: 'path_provider unavailable after ${delays.length + 1} attempts — ' message: 'path_provider unavailable after ${delays.length + 1} attempts — '
@@ -616,23 +865,73 @@ Future<String> _resolveDatabasePath() async {
); );
} }
// These two functions are only called from unit tests (database_path_test.dart). // Reads /proc/self/cmdline to extract the Android package name, then
// constructs the standard app files-dir path without a platform channel.
// Returns null when the path cannot be determined or created.
Future<String?> _androidFallbackPath() async {
try {
final bytes = await File('/proc/self/cmdline').readAsBytes();
final end = bytes.indexOf(0);
final packageName = String.fromCharCodes(
end >= 0 ? bytes.sublist(0, end) : bytes,
).trim();
// A valid Android package name contains dots but not slashes.
if (packageName.isEmpty ||
!packageName.contains('.') ||
packageName.contains('/')) {
return null;
}
for (final base in [
'/data/user/0/$packageName/files',
'/data/data/$packageName/files',
]) {
try {
await Directory(base).create(recursive: true);
return p.join(base, 'sharedinbox.db');
} catch (_) {
continue;
}
}
return null;
} catch (_) {
return null;
}
}
// These functions are only called from unit tests (database_path_test.dart).
// They expose internals that cannot be reached via the public API. // They expose internals that cannot be reached via the public API.
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath(); Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null; void resetDatabasePathForTesting() => _dbPath = null;
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
/// Configures PRAGMAs on a newly opened SQLite connection.
///
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
/// instead of immediately failing.
///
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
/// WorkManager background task may already have the DB open when the app
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
/// returned in that situation; it only occurs when the DB is already in WAL
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
void _setupPragmas(Database db) {
db.execute('PRAGMA busy_timeout = 5000;');
try {
db.execute('PRAGMA journal_mode = WAL;');
} on SqliteException catch (e) {
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
if (e.resultCode != 5) rethrow;
}
}
LazyDatabase _openConnection() { LazyDatabase _openConnection() {
return LazyDatabase(() async { return LazyDatabase(() async {
final file = File(await _resolveDatabasePath()); final file = File(await _resolveDatabasePath());
return NativeDatabase.createInBackground( return NativeDatabase.createInBackground(file, setup: _setupPragmas);
file,
setup: (db) {
// WAL lets readers and writers proceed concurrently (different account
// sync loops share the same DB). busy_timeout makes SQLite retry for
// up to 5 s instead of immediately returning SQLITE_BUSY.
db.execute('PRAGMA journal_mode = WAL;');
db.execute('PRAGMA busy_timeout = 5000;');
},
);
}); });
} }
// Exposed so tests can run the exact production setup logic on a raw
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
void setupPragmasForTesting(Database db) => _setupPragmas(db);
+11 -16
View File
@@ -9,8 +9,9 @@ class LocalSieveRepository {
final AppDatabase _db; final AppDatabase _db;
Future<List<SieveScript>> listScripts(String accountId) async { Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select(_db.localSieveScripts) final rows = await (_db.select(
..where((t) => t.accountId.equals(accountId))) _db.localSieveScripts,
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
return rows return rows
.map( .map(
@@ -26,10 +27,9 @@ class LocalSieveRepository {
Future<String> getScriptContent(String accountId, String blobId) async { Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId); final rowId = int.parse(blobId);
final row = await (_db.select(_db.localSieveScripts) final row = await (_db.select(
..where( _db.localSieveScripts,
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId'); if (row == null) throw Exception('Local script not found: $blobId');
return row.content; return row.content;
@@ -44,9 +44,7 @@ class LocalSieveRepository {
if (id != null) { if (id != null) {
final rowId = int.parse(id); final rowId = int.parse(id);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where( ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write( .write(
LocalSieveScriptsCompanion( LocalSieveScriptsCompanion(
name: Value(name), name: Value(name),
@@ -78,10 +76,9 @@ class LocalSieveRepository {
Future<void> deleteScript(String accountId, String scriptId) async { Future<void> deleteScript(String accountId, String scriptId) async {
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.delete(_db.localSieveScripts) await (_db.delete(
..where( _db.localSieveScripts,
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
))
.go(); .go();
} }
@@ -92,9 +89,7 @@ class LocalSieveRepository {
.write(const LocalSieveScriptsCompanion(isActive: Value(false))); .write(const LocalSieveScriptsCompanion(isActive: Value(false)));
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where( ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(const LocalSieveScriptsCompanion(isActive: Value(true))); .write(const LocalSieveScriptsCompanion(isActive: Value(true)));
}); });
} }
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl( DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
this._db, : _imapConnect = imapConnect;
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -124,10 +121,7 @@ class DraftRepositoryImpl implements DraftRepository {
} }
} }
Future<void> _syncWithServer( Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
imap.ImapClient client,
String accountId,
) async {
// Create/select the Drafts folder. // Create/select the Drafts folder.
try { try {
await client.createMailbox('Drafts'); await client.createMailbox('Drafts');
@@ -162,8 +156,9 @@ class DraftRepositoryImpl implements DraftRepository {
? uidList.first.toString() ? uidList.first.toString()
: null; : null;
if (uid != null) { if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))) await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
.write(DraftsCompanion(imapServerId: Value(uid))); DraftsCompanion(imapServerId: Value(uid)),
);
} }
} }
+252 -88
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart' as account_model; import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/email.dart' as model; import 'package:sharedinbox/core/models/email.dart' as model;
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -95,6 +96,26 @@ class EmailRepositoryImpl implements EmailRepository {
.map((rows) => rows.map(_threadRowToModel).toList()); .map((rows) => rows.map(_threadRowToModel).toList());
} }
@override
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
final query = _db.select(_db.threads).join([
innerJoin(
_db.mailboxes,
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
),
]);
query
..where(_db.mailboxes.role.equals('inbox'))
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
..limit(limit);
return query.watch().map(
(rows) => rows
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
.toList(),
);
}
model.EmailThread _threadRowToModel(ThreadRow row) { model.EmailThread _threadRowToModel(ThreadRow row) {
List<model.EmailAddress> parseAddresses(String json) { List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>; final list = jsonDecode(json) as List<dynamic>;
@@ -156,6 +177,7 @@ class EmailRepositoryImpl implements EmailRepository {
return; return;
} }
if (threadEmails.isEmpty) return;
final latest = threadEmails.last; final latest = threadEmails.last;
// Collect unique participants across the whole thread. // Collect unique participants across the whole thread.
@@ -237,7 +259,12 @@ class EmailRepositoryImpl implements EmailRepository {
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
final msg = fetch.messages.first; final msg = fetch.messages.firstOrNull;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final textBody = msg.decodeTextPlainPart(); final textBody = msg.decodeTextPlainPart();
final rawHtml = msg.decodeTextHtmlPart(); final rawHtml = msg.decodeTextHtmlPart();
final htmlBody = final htmlBody =
@@ -325,13 +352,7 @@ class EmailRepositoryImpl implements EmailRepository {
], ],
'fetchHTMLBodyValues': true, 'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true, 'fetchTextBodyValues': true,
'bodyProperties': [ 'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
'partId',
'type',
'name',
'size',
'subParts',
],
}, },
'0', '0',
], ],
@@ -540,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
for (final msg in result.messages) { for (final msg in result.messages) {
final uid = msg.uid; final uid = msg.uid;
if (uid == null) continue; if (uid == null) continue;
final emailId = '${account.id}:$uid'; final emailId = '${account.id}:$mailboxPath:$uid';
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write( await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
EmailsCompanion( EmailsCompanion(
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false), isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
@@ -595,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
continue; continue;
} }
bytes += msg.size ?? 0; bytes += msg.size ?? 0;
final emailId = '${account.id}:$uid'; final emailId = '${account.id}:$mailboxPath:$uid';
final msgId = envelope.messageId?.trim(); final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim(); final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim(); final refs = msg.getHeaderValue('References')?.trim();
@@ -1949,8 +1970,9 @@ class EmailRepositoryImpl implements EmailRepository {
.getSingleOrNull(); .getSingleOrNull();
final inboxPath = inboxMailbox?.path ?? 'INBOX'; final inboxPath = inboxMailbox?.path ?? 'INBOX';
final alreadyApplied = await (_db.select(_db.localSieveApplied) final alreadyApplied = await (_db.select(
..where((t) => t.accountId.equals(accountId))) _db.localSieveApplied,
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
@@ -2050,7 +2072,9 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(1)) ..limit(1))
.getSingleOrNull(); .getSingleOrNull();
if (destMailbox == null) { if (destMailbox == null) {
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}'); log(
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
);
return; return;
} }
destPath = destMailbox.path; destPath = destMailbox.path;
@@ -2808,11 +2832,13 @@ class EmailRepositoryImpl implements EmailRepository {
// Content-Transfer-Encoding) and getPart() can decode the part correctly. // Content-Transfer-Encoding) and getPart() can decode the part correctly.
// A partial BODY.PEEK[n] fetch omits those headers, causing // A partial BODY.PEEK[n] fetch omits those headers, causing
// decodeContentBinary() to return raw base64 instead of decoded bytes. // decodeContentBinary() to return raw base64 instead of decoded bytes.
final fetch = await client.uidFetchMessage( final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
emailRow.uid, final msg = fetch.messages.firstOrNull;
'BODY.PEEK[]', if (msg == null) {
); throw StateError(
final msg = fetch.messages.first; 'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final part = msg.getPart(attachment.fetchPartId) ?? msg; final part = msg.getPart(attachment.fetchPartId) ?? msg;
final bytes = part.decodeContentBinary(); final bytes = part.decodeContentBinary();
if (bytes == null) { if (bytes == null) {
@@ -2874,11 +2900,14 @@ class EmailRepositoryImpl implements EmailRepository {
); );
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage( final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
emailRow.uid, final msg = fetch.messages.firstOrNull;
'BODY.PEEK[]', if (msg == null) {
); throw StateError(
return fetch.messages.first.renderMessage(); 'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
return msg.renderMessage();
} finally { } finally {
await client.logout(); await client.logout();
} }
@@ -2894,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
final sql = accountId != null final sql = accountId != null
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' ? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50' ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' : 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50'; ' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
final variables = accountId != null final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)] ? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)]; : [Variable<String>(ftsQuery)];
@@ -2906,18 +2935,151 @@ class EmailRepositoryImpl implements EmailRepository {
final emailRows = await Future.wait( final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)), queryRows.map((r) => _db.emails.mapFromRow(r)),
); );
final noteRows = await _searchEmailsByNotes(accountId, null, query);
final seen = <String>{};
final merged = <model.Email>[];
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
if (seen.add(e.id)) merged.add(e);
}
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
return merged;
}
/// Returns emails whose associated notes contain all words from [query].
/// Optionally filtered by [accountId] and [mailboxPath].
Future<List<model.Email>> _searchEmailsByNotes(
String? accountId,
String? mailboxPath,
String query,
) async {
final words =
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return [];
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
final extraConditions = StringBuffer();
final extraVars = <Variable<String>>[];
if (accountId != null) {
extraConditions.write(' AND e.account_id = ?');
extraVars.add(Variable<String>(accountId));
}
if (mailboxPath != null) {
extraConditions.write(' AND e.mailbox_path = ?');
extraVars.add(Variable<String>(mailboxPath));
}
final sql = 'SELECT DISTINCT e.* FROM emails e'
' JOIN email_notes n ON n.message_id = e.message_id'
' AND n.account_id = e.account_id'
' WHERE $noteConditions$extraConditions'
' ORDER BY e.received_at DESC LIMIT 50';
final rows = await _db.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
).get();
final emailRows =
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
return emailRows.map(_toModel).toList(); return emailRows.map(_toModel).toList();
} }
@override
Future<List<model.Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async {
final rows = await (_db.select(_db.emails)
..where((t) {
final fe = _filterGroup(filter, t);
if (accountId == null) return fe;
return t.accountId.equals(accountId) & fe;
})
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(100))
.get();
return rows.map(_toModel).toList();
}
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
if (group.isEmpty) return const Constant(true);
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
return switch (group.operator) {
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
};
}
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
switch (node) {
final FilterLeaf l => _filterLeaf(l, t),
final FilterGroup g => _filterGroup(g, t),
};
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
final val = leaf.value.toLowerCase();
return switch (leaf.field) {
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
// Size is not stored in the local cache; skip silently.
FilterField.size => const Constant(true),
};
}
Expression<bool> _jsonLike(
GeneratedColumn<String> col,
FilterComparison comp,
String val,
) =>
switch (comp) {
FilterComparison.contains => col.like('%$val%'),
FilterComparison.is_ => col.like('%"email":"$val"%'),
FilterComparison.matches => col.like(_globToLike(val)),
_ => const Constant(true),
};
Expression<bool> _textLike(
GeneratedColumn<String> col,
FilterComparison comp,
String val,
) =>
switch (comp) {
FilterComparison.contains => col.like('%$val%'),
FilterComparison.is_ => col.like(val),
FilterComparison.matches => col.like(_globToLike(val)),
_ => const Constant(true),
};
static String _globToLike(String glob) {
final buf = StringBuffer();
for (var i = 0; i < glob.length; i++) {
final ch = glob[i];
if (ch == '%' || ch == '_') {
buf.write('\\$ch');
} else if (ch == '*') {
buf.write('%');
} else if (ch == '?') {
buf.write('_');
} else {
buf.write(ch);
}
}
return buf.toString();
}
/// Converts a user query string into an FTS5 match expression. /// Converts a user query string into an FTS5 match expression.
/// Each whitespace-separated word becomes a prefix term (word*) so that /// Each whitespace-separated word becomes a prefix term (word*) so that
/// partial words still match. Special FTS5 characters are stripped. /// partial words still match. Special FTS5 characters are stripped.
static String _toFtsQuery(String query) { static String _toFtsQuery(String query) {
final words = query final words = query
.trim() .trim()
.split(RegExp(r'\s+')) .split(RegExp(r'[^\w]+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.where((w) => w.isNotEmpty) .where((w) => w.isNotEmpty)
.toList(); .toList();
if (words.isEmpty) return ''; if (words.isEmpty) return '';
@@ -2955,6 +3117,20 @@ class EmailRepositoryImpl implements EmailRepository {
}) async { }) async {
if (query.length < 2) return []; if (query.length < 2) return [];
final pattern = '%${query.toLowerCase()}%'; final pattern = '%${query.toLowerCase()}%';
// Addresses we deliberately wrote to (sent folder) should appear before
// addresses that happened to email us (inbox/other folders).
final sentMailboxes = await (_db.select(_db.mailboxes)
..where((t) {
Expression<bool> cond = t.role.equals('sent');
if (accountId != null) {
cond = t.accountId.equals(accountId) & cond;
}
return cond;
}))
.get();
final sentPaths = {for (final m in sentMailboxes) m.path};
final rows = await (_db.select(_db.emails) final rows = await (_db.select(_db.emails)
..where((t) { ..where((t) {
Expression<bool> cond = const Constant(true); Expression<bool> cond = const Constant(true);
@@ -2969,11 +3145,22 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(100)) ..limit(100))
.get(); .get();
// Two passes: sent-folder rows first (prioritise recipients we chose),
// then other rows (senders who contacted us).
final sortedRows = [
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
];
final seen = <String>{}; final seen = <String>{};
final results = <model.EmailAddress>[]; final results = <model.EmailAddress>[];
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
for (final row in rows) { for (final row in sortedRows) {
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) { final isSent = sentPaths.contains(row.mailboxPath);
final fields = isSent
? [row.toAddresses, row.ccJson, row.fromJson]
: [row.fromJson, row.toAddresses, row.ccJson];
for (final jsonStr in fields) {
final list = jsonDecode(jsonStr) as List<dynamic>; final list = jsonDecode(jsonStr) as List<dynamic>;
for (final e in list) { for (final e in list) {
final map = e as Map<String, dynamic>; final map = e as Map<String, dynamic>;
@@ -2994,68 +3181,42 @@ class EmailRepositoryImpl implements EmailRepository {
} }
@override @override
// Results are limited to emails already synced into the local SQLite FTS5
// index; call syncEmails first to ensure the index is up-to-date.
Future<List<model.Email>> searchEmails( Future<List<model.Email>> searchEmails(
String accountId, String accountId,
String mailboxPath, String mailboxPath,
String query, String query,
) async { ) async {
final account = (await _accounts.getAccount(accountId))!; final ftsQuery = _toFtsQuery(query);
final password = await _accounts.getPassword(accountId); if (ftsQuery.isEmpty) return [];
final client = await _imapConnect(
account, const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
_effectiveUsername(account), ' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
password, ' ORDER BY e.received_at DESC LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
Variable<String>(mailboxPath),
];
final queryRows = await _db
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)),
); );
try {
await client.selectMailboxByPath(mailboxPath);
final terms =
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
final searchCriteria = terms.map((term) {
final escaped = term.replaceAll('"', '\\"');
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
}).join(' ');
final result = await client.uidSearchMessages(
searchCriteria: searchCriteria,
);
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return [];
final fetch = await client.uidFetchMessages( final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
imap.MessageSequence.fromIds(uids, isUid: true),
'(UID FLAGS ENVELOPE)', final seen = <String>{};
); final merged = <model.Email>[];
return fetch.messages for (final e in [...emailRows.map(_toModel), ...noteRows]) {
.where((msg) => msg.uid != null && msg.envelope != null) if (seen.add(e.id)) merged.add(e);
.map((msg) {
final envelope = msg.envelope!;
final uid = msg.uid!;
final emailId = '$accountId:$uid';
return model.Email(
id: emailId,
accountId: accountId,
mailboxPath: mailboxPath,
uid: uid,
subject: envelope.subject,
sentAt: envelope.date,
receivedAt: envelope.date ?? DateTime.now(),
from: _toAddressList(envelope.from),
to: _toAddressList(envelope.to),
cc: _toAddressList(envelope.cc),
isSeen: msg.flags?.contains(r'\Seen') ?? false,
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
hasAttachment: msg.hasAttachments(),
);
}).toList();
} finally {
await client.logout();
} }
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
return merged;
} }
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
(addresses ?? const [])
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
.toList();
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
/// Computes a stable threadId from RFC 2822 headers. /// Computes a stable threadId from RFC 2822 headers.
@@ -3252,14 +3413,17 @@ class EmailRepositoryImpl implements EmailRepository {
await _db.customStatement('PRAGMA foreign_keys = OFF'); await _db.customStatement('PRAGMA foreign_keys = OFF');
try { try {
await _db.transaction(() async { await _db.transaction(() async {
await (_db.delete(_db.emails) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.emails,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete(_db.pendingChanges) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.pendingChanges,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete(_db.syncStates) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.syncStates,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
}); });
} finally { } finally {
@@ -79,6 +79,15 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
try { try {
final mailboxes = await client.listMailboxes(recursive: true); final mailboxes = await client.listMailboxes(recursive: true);
// Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(
_db.mailboxes,
)..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) { for (final mb in mailboxes) {
final path = mb.path; final path = mb.path;
final id = '${account.id}:$path'; final id = '${account.id}:$path';
@@ -96,6 +105,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e'); log('STATUS skipped for $path: $e');
} }
// Use the server-assigned role when available; fall back to the
// existing DB role so that manually-created folders (e.g. a user
// who just created their Archive folder) keep their role across syncs
// when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id];
await _db.into(_db.mailboxes).insertOnConflictUpdate( await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
@@ -104,7 +119,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name, name: mb.name,
unreadCount: Value(unread), unreadCount: Value(unread),
totalCount: Value(total), totalCount: Value(total),
role: Value(_imapRole(mb)), role: Value(role),
), ),
); );
} }
@@ -306,8 +321,127 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete(_db.mailboxes) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.mailboxes,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@override
Future<model.Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, role);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, role);
}
}
@override
Future<model.Mailbox> createMailbox(String accountId, String name) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, null);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, null);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String? role,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.createMailbox(name);
} finally {
await client.logout();
}
final id = '${account.id}:$name';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: name,
name: name,
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
Future<model.Mailbox> _createMailboxWithRoleJmap(
account_model.Account account,
String password,
String name,
String? role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final responses = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {
'name': name,
if (role != null) 'role': role,
},
},
},
'0',
],
]);
final result = _responseArgs(responses, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newId =
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
if (newId == null) {
throw Exception(
'Failed to create mailbox "$name": server returned no ID',
);
}
final dbId = '${account.id}:$newId';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: account.id,
path: newId,
name: name,
role: Value(role),
),
);
final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
} }
@@ -0,0 +1,570 @@
import 'dart:math' as math;
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/jmap_client.dart';
const _notesFolder = 'Notes';
const _headerNoteFor = 'X-SharedInbox-Note-For';
const _headerNoteId = 'X-SharedInbox-Note-Id';
class NoteRepositoryImpl implements NoteRepository {
NoteRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn imapConnect = connectImap,
http.Client? httpClient,
}) : _imapConnect = imapConnect,
_httpClient = httpClient ?? http.Client();
final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn _imapConnect;
final http.Client _httpClient;
String _effectiveUsername(account_model.Account account) =>
account.username.isNotEmpty ? account.username : account.email;
// ── Observe (local cache) ─────────────────────────────────────────────────
@override
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
return (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(accountId) & t.messageId.equals(messageId),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
// ── Sync (server → local cache) ──────────────────────────────────────────
@override
Future<void> syncNotes(String accountId, String messageId) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
await _syncNotesImap(account, password, messageId);
case account_model.AccountType.jmap:
await _syncNotesJmap(account, password, messageId);
}
}
Future<void> _syncNotesImap(
account_model.Account account,
String password,
String messageId,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
} catch (_) {
// Notes folder doesn't exist — nothing to sync.
return;
}
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
final searchResult = await client.uidSearchMessages(
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
);
final uids = searchResult.matchingSequence?.toList() ?? [];
if (uids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
final fetchedIds = <String>{};
for (final msg in fetch.messages) {
final uid = msg.uid;
if (uid == null) continue;
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
if (noteId == null || noteId.isEmpty) continue;
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: msg.decodeTextPlainPart() ?? '',
serverId: uid.toString(),
createdAt: msg.decodeDate() ?? DateTime.now(),
),
);
}
// Remove stale local notes (deleted on the server).
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
} finally {
await client.logout();
}
}
Future<void> _syncNotesJmap(
account_model.Account account,
String password,
String messageId,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findNotesMailboxJmap(jmap);
if (mailboxId == null) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final queryResp = await jmap.call([
[
'Email/query',
{
'accountId': jmap.accountId,
'filter': {'inMailbox': mailboxId},
},
'0',
],
]);
final ids = List<String>.from(
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
);
if (ids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final getResp = await jmap.call([
[
'Email/get',
{
'accountId': jmap.accountId,
'ids': ids,
'properties': [
'id',
'receivedAt',
'textBody',
'bodyValues',
'header:$_headerNoteFor:asText',
'header:$_headerNoteId:asText',
],
'fetchTextBodyValues': true,
},
'0',
],
]);
final list =
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
final fetchedIds = <String>{};
for (final e in list) {
final m = e as Map<String, dynamic>;
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
if (noteFor != messageId) continue;
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
if (noteId == null || noteId.isEmpty) continue;
final jmapEmailId = m['id'] as String;
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
var noteText = '';
if (textBodyParts.isNotEmpty) {
final partId =
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
if (partId != null) {
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
as String? ??
'';
}
}
final createdAt =
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: noteText,
serverId: jmapEmailId,
createdAt: createdAt,
),
);
}
// Remove stale local notes.
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) & t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
}
// ── Add ───────────────────────────────────────────────────────────────────
@override
Future<void> addNote(
String accountId,
String messageId,
String text,
) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
final noteId = _generateId();
switch (account.type) {
case account_model.AccountType.imap:
await _addNoteImap(account, password, messageId, noteId, text);
case account_model.AccountType.jmap:
await _addNoteJmap(account, password, messageId, noteId, text);
}
}
Future<void> _addNoteImap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.createMailbox(_notesFolder);
} catch (_) {
// Already exists.
}
final builder = imap.MessageBuilder()
..subject = 'Note'
..text = text;
builder.addHeader(_headerNoteFor, messageId);
builder.addHeader(_headerNoteId, noteId);
final mime = builder.buildMimeMessage();
final appendResult = await client.appendMessage(
mime,
targetMailboxPath: _notesFolder,
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final serverId = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: serverId,
createdAt: DateTime.now(),
),
);
} finally {
await client.logout();
}
}
Future<void> _addNoteJmap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
const bodyPartId = '1';
final setResp = await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'create': {
'new-note': {
'mailboxIds': {mailboxId: true},
'subject': 'Note',
'keywords': {r'$seen': true},
'headers': [
{'name': _headerNoteFor, 'value': ' $messageId'},
{'name': _headerNoteId, 'value': ' $noteId'},
],
'bodyValues': {
bodyPartId: {
'value': text,
'isEncodingProblem': false,
'isTruncated': false,
},
},
'textBody': [
{'partId': bodyPartId, 'type': 'text/plain'},
],
},
},
},
'0',
],
]);
final result = _responseArgs(setResp, 0, 'Email/set');
final created = result['created'] as Map<String, dynamic>?;
final newEmail = created?['new-note'] as Map<String, dynamic>?;
final jmapEmailId = newEmail?['id'] as String? ?? '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: jmapEmailId,
createdAt: DateTime.now(),
),
);
}
// ── Delete ────────────────────────────────────────────────────────────────
@override
Future<void> deleteNote(String noteId) async {
final noteRow = await (_db.select(_db.emailNotes)
..where((t) => t.id.equals(noteId)))
.getSingleOrNull();
if (noteRow == null) return;
final account = await _accounts.getAccount(noteRow.accountId);
if (account == null) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
.go();
return;
}
final password = await _accounts.getPassword(account.id);
switch (account.type) {
case account_model.AccountType.imap:
await _deleteNoteImap(account, password, noteRow);
case account_model.AccountType.jmap:
await _deleteNoteJmap(account, password, noteRow);
}
}
Future<void> _deleteNoteImap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
final uid = int.tryParse(noteRow.serverId);
if (uid != null) {
final seq = imap.MessageSequence.fromId(uid, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
}
} catch (_) {
// Notes folder gone or message already deleted — clean up locally.
}
} finally {
await client.logout();
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
Future<void> _deleteNoteJmap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
if (noteRow.serverId.isNotEmpty) {
await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'destroy': [noteRow.serverId],
},
'0',
],
]);
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
// ── JMAP helpers ──────────────────────────────────────────────────────────
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
final resp = await jmap.call([
[
'Mailbox/get',
{'accountId': jmap.accountId, 'ids': null},
'0',
],
]);
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
for (final m in list) {
final map = m as Map<String, dynamic>;
if (map['name'] == _notesFolder) return map['id'] as String?;
}
return null;
}
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
final existing = await _findNotesMailboxJmap(jmap);
if (existing != null) return existing;
final resp = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-notes': {'name': _notesFolder},
},
},
'0',
],
]);
final result = _responseArgs(resp, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
return newMailbox?['id'] as String? ?? _notesFolder;
}
Map<String, dynamic> _responseArgs(
List<dynamic> responses,
int index,
String expectedMethod,
) {
final triple = responses[index] as List<dynamic>;
final method = triple[0] as String;
if (method == 'error') {
final err = triple[1] as Map<String, dynamic>;
throw JmapException('$expectedMethod error: ${err['type']}');
}
return triple[1] as Map<String, dynamic>;
}
EmailNote _toModel(EmailNoteRow row) => EmailNote(
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
noteText: row.noteText,
serverId: row.serverId,
createdAt: row.createdAt,
);
// Generates a random UUID v4.
static String _generateId() {
final rng = math.Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
'-${hex.substring(20)}';
}
}
@@ -24,8 +24,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
await _db.transaction(() async { await _db.transaction(() async {
// Remove existing entry for same query (deduplication). // Remove existing entry for same query (deduplication).
await (_db.delete(_db.searchHistoryEntries) await (_db.delete(
..where((t) => t.query.equals(trimmed))) _db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed)))
.go(); .go();
await _db.into(_db.searchHistoryEntries).insert( await _db.into(_db.searchHistoryEntries).insert(
@@ -43,8 +44,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
.get(); .get();
if (keepIds.isNotEmpty) { if (keepIds.isNotEmpty) {
await (_db.delete(_db.searchHistoryEntries) await (_db.delete(
..where((t) => t.id.isNotIn(keepIds))) _db.searchHistoryEntries,
)..where((t) => t.id.isNotIn(keepIds)))
.go(); .go();
} }
}); });
@@ -40,8 +40,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
await _pruneExpired(); await _pruneExpired();
final keyIdHex = _hex(keyId); final keyIdHex = _hex(keyId);
final row = await (_db.select(_db.shareKeys) final row = await (_db.select(
..where((t) => t.id.equals(keyIdHex))) _db.shareKeys,
)..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) return null; if (row == null) return null;
@@ -55,10 +56,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
} }
Future<void> _pruneExpired() async { Future<void> _pruneExpired() async {
await (_db.delete(_db.shareKeys) await (_db.delete(
..where( _db.shareKeys,
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()), )..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
))
.go(); .go();
} }
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId, accountId: accountId,
result: success ? 'ok' : 'error', result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage), errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol), protocol: Value(protocol),
itemsSynced: Value(emailsFetched), itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped), emailsSkipped: Value(emailsSkipped),
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id, id: r.id,
result: r.result, result: r.result,
errorMessage: r.errorMessage, errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol, protocol: r.protocol,
emailsFetched: r.itemsSynced, emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped, emailsSkipped: r.emailsSkipped,
@@ -0,0 +1,117 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
UserPreferencesRepositoryImpl(this._db);
final AppDatabase _db;
static const _rowId = 1;
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@override
Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
menuPosition: Value(position.name),
),
);
}
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
@override
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
prefetchMode: Value(mode.name),
),
);
}
@override
Future<void> updateBodyCacheLimitMb(int mb) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
bodyCacheLimitMb: Value(mb),
),
);
}
@override
Stream<List<String>> observeTrustedImageSenders() {
return (_db.select(_db.imageTrustedSenders)
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
.watch()
.map((rows) => rows.map((r) => r.senderEmail).toList());
}
@override
Future<void> addTrustedImageSender(String senderEmail) async {
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
ImageTrustedSendersCompanion(
senderEmail: Value(senderEmail.toLowerCase()),
addedAt: Value(DateTime.now()),
),
);
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
await (_db.delete(_db.imageTrustedSenders)
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
.go();
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences();
return pref.UserPreferences(
menuPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom,
),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
bodyCacheLimitMb: row.bodyCacheLimitMb,
);
}
}
+84 -7
View File
@@ -4,15 +4,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -21,7 +25,8 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart'; import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody; import 'package:sharedinbox/data/db/database.dart'
hide Email, EmailBody, UserPreferences;
import 'package:sharedinbox/data/db/local_sieve_repository.dart'; import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart'; import 'package:sharedinbox/data/jmap/sieve_repository.dart';
@@ -29,10 +34,12 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart'; import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart'; import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart'; import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart'; import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
/// Swappable IMAP connection factory — override in tests to use plaintext. /// Swappable IMAP connection factory — override in tests to use plaintext.
@@ -97,8 +104,9 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider = final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
Provider<SearchHistoryRepository>((ref) { ref,
) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -131,8 +139,10 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider = final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
StreamProvider.autoDispose.family<bool, String>((ref, accountId) { ref,
accountId,
) {
return ref.watch(syncManagerProvider).watchSyncing(accountId); return ref.watch(syncManagerProvider).watchSyncing(accountId);
}); });
@@ -181,8 +191,9 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
final undoServiceProvider = final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new); UndoService.new,
);
/// Loads email header + body and marks the email as seen. /// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -203,10 +214,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
repo.getEmailBody(_emailId), repo.getEmailBody(_emailId),
]); ]);
unawaited(repo.setFlag(_emailId, seen: true)); unawaited(repo.setFlag(_emailId, seen: true));
final header = results[0] as Email?;
if (header != null) {
unawaited(_prefetchNextEmailBody(repo, header));
}
return (results[0] as Email?, results[1] as EmailBody); return (results[0] as Email?, results[1] as EmailBody);
} }
Future<void> _prefetchNextEmailBody(
EmailRepository repo,
Email header,
) async {
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return;
final threads =
await repo.observeThreads(header.accountId, header.mailboxPath).first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(_emailId),
);
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
final nextId = threads[currentIndex + 1].latestEmailId;
await repo.getEmailBody(nextId);
}
} }
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
return ref.watch(accountRepositoryProvider).observeAccounts();
});
final accountByIdProvider = final accountByIdProvider =
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) { StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref.watch(accountRepositoryProvider).observeAccounts().map( return ref.watch(accountRepositoryProvider).observeAccounts().map(
@@ -227,3 +266,41 @@ final accountConnectionStatusProvider =
.read(connectionTestServiceProvider) .read(connectionTestServiceProvider)
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
ref,
) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
ref,
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
final trustedImageSendersProvider =
StreamProvider.autoDispose<List<String>>((ref) {
return ref
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
return NoteRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
});
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
return ref.watch(dbProvider).loadInstalledVersions();
});
/// Stream of notes for a specific email, identified by (accountId, messageId).
final notesProvider =
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
(ref, params) =>
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
);
+41 -5
View File
@@ -5,19 +5,30 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override; import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/services/notification_service.dart'; import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart'; import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/router.dart'; import 'package:sharedinbox/ui/router.dart';
import 'package:sharedinbox/ui/screens/crash_screen.dart'; import 'package:sharedinbox/ui/screens/crash_screen.dart';
import 'package:stack_trace/stack_trace.dart' as stack_trace;
void main({List<Override> overrides = const []}) async { void main({List<Override> overrides = const []}) {
unawaited( unawaited(
runZonedGuarded( runZonedGuarded(
() async { () async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Dart's async machinery propagates stack traces in chain format
// (with '===== asynchronous gap =====' separators). Flutter's
// StackFrame parser asserts on those lines, so strip them first.
FlutterError.demangleStackTrace = (StackTrace s) {
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
if (s is stack_trace.Trace) return s.vmTrace;
return s;
};
// Catch errors during build (e.g. layout exceptions) and show CrashScreen. // Catch errors during build (e.g. layout exceptions) and show CrashScreen.
ErrorWidget.builder = (details) => CrashScreen( ErrorWidget.builder = (details) => CrashScreen(
exception: details.exception, exception: details.exception,
@@ -39,19 +50,35 @@ void main({List<Override> overrides = const []}) async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
await initNotifications(); await initNotifications();
await registerBackgroundSync(); await registerBackgroundSync();
await _registerPrefetchTaskFromStoredPrefs();
} }
runApp( runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()), ProviderScope(overrides: overrides, child: const SharedInboxApp()),
); );
}, },
(error, stack) { // This handler runs in the parent zone — runApp cannot be called here.
// Catch unhandled async errors. // Framework errors are already handled by FlutterError.onError above.
runApp(CrashScreen(exception: error, stackTrace: stack)); (error, stack) => FlutterError.reportError(
}, FlutterErrorDetails(exception: error, stack: stack),
),
), ),
); );
} }
/// Reads the stored prefetch preference and registers the WorkManager task
/// with the correct network constraint for it. Opens and immediately closes
/// a temporary DB connection; safe because initDatabasePath() has already run.
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
final db = AppDatabase();
try {
final row = await db.select(db.userPreferences).getSingleOrNull();
final mode = PrefetchMode.fromString(row?.prefetchMode);
await registerBodyPrefetchTask(mode);
} finally {
await db.close();
}
}
class SharedInboxApp extends ConsumerStatefulWidget { class SharedInboxApp extends ConsumerStatefulWidget {
const SharedInboxApp({super.key}); const SharedInboxApp({super.key});
@@ -59,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState(); ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
} }
const _kGitHash = String.fromEnvironment('GIT_HASH');
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> { class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@override @override
void initState() { void initState() {
@@ -66,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
// Start background IMAP sync once — runs for the lifetime of the app. // Start background IMAP sync once — runs for the lifetime of the app.
ref.read(syncManagerProvider).start(); ref.read(syncManagerProvider).start();
ref.read(reliabilityRunnerProvider).start(); ref.read(reliabilityRunnerProvider).start();
if (_kGitHash.isNotEmpty) {
unawaited(
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
);
}
} }
@override @override
@@ -75,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true, useMaterial3: true,
splashFactory: NoSplash.splashFactory,
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
@@ -82,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
useMaterial3: true, useMaterial3: true,
splashFactory: NoSplash.splashFactory,
), ),
routerConfig: router, routerConfig: router,
); );
+35 -1
View File
@@ -1,6 +1,7 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/sieve_script.dart'; import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart'; import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart';
@@ -8,7 +9,9 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
import 'package:sharedinbox/ui/screens/account_send_screen.dart'; import 'package:sharedinbox/ui/screens/account_send_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart'; import 'package:sharedinbox/ui/screens/changelog_screen.dart';
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart'; import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'package:sharedinbox/ui/screens/edit_account_screen.dart'; import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
@@ -19,15 +22,22 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart'; import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart';
final router = GoRouter( final router = GoRouter(
initialLocation: '/accounts', initialLocation: '/inbox',
routes: [ routes: [
ShellRoute( ShellRoute(
builder: (ctx, state, child) => UndoShell(child: child), builder: (ctx, state, child) => UndoShell(child: child),
routes: [ routes: [
GoRoute(
path: '/inbox',
builder: (ctx, state) => const CombinedInboxScreen(),
),
GoRoute( GoRoute(
path: '/accounts', path: '/accounts',
builder: (ctx, state) => const AccountListScreen(), builder: (ctx, state) => const AccountListScreen(),
@@ -47,6 +57,14 @@ final router = GoRouter(
GoRoute( GoRoute(
path: 'undo-log', path: 'undo-log',
builder: (ctx, state) => const UndoLogScreen(), builder: (ctx, state) => const UndoLogScreen(),
routes: [
GoRoute(
path: ':actionId',
builder: (ctx, state) => UndoLogDetailScreen(
action: state.extra as UndoAction,
),
),
],
), ),
GoRoute( GoRoute(
path: 'changelog', path: 'changelog',
@@ -56,6 +74,16 @@ final router = GoRouter(
path: 'about', path: 'about',
builder: (ctx, state) => const AboutScreen(), builder: (ctx, state) => const AboutScreen(),
), ),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: 'trusted-senders',
builder: (ctx, state) => TrustedImageSendersScreen(
highlightedSender: state.extra as String?,
),
),
GoRoute( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
@@ -159,6 +187,12 @@ final router = GoRouter(
); );
}, },
), ),
GoRoute(
path: '/bug-report',
builder: (ctx, state) => BugReportScreen(
emailId: state.uri.queryParameters['emailId'],
),
),
], ],
), ),
], ],
+82 -60
View File
@@ -1,13 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
@@ -19,53 +20,22 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform(); final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture;
late final Stream<List<Account>> _accountsStream; late final Stream<List<Account>> _accountsStream;
String? _deviceModel;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts(); _accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
_deviceModelFuture = getDeviceModel();
unawaited(
_deviceModelFuture.then((model) {
if (mounted) setState(() => _deviceModel = model);
}),
);
} }
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount,
) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
return '## sharedinbox.de\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard( Future<void> _copyToClipboard(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -75,10 +45,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount), text: buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
), ),
); );
if (context.mounted) { if (context.mounted) {
@@ -91,6 +71,32 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
} }
} }
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
Future<void> _createIssue( Future<void> _createIssue(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -100,16 +106,28 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
final body = Uri.encodeComponent( final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount), buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
); );
final url = Uri.parse( final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
); );
try { try {
final launched = final launched = await launchUrl(
await launchUrl(url, mode: LaunchMode.externalApplication); url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -153,21 +171,17 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Markdown( return Markdown(
data: _buildMarkdown( data: buildAboutMarkdown(
context, context: context,
snapshot.data, pkg: snapshot.data,
imapCount, imapCount: imapCount,
jmapCount, jmapCount: jmapCount,
deviceModel: _deviceModel,
), ),
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited( unawaited(_launchUrl(context, Uri.parse(href)));
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
);
} }
}, },
); );
@@ -184,22 +198,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
label: const Text('Copy to clipboard'), label: const Text('Copy info'),
onPressed: () => unawaited( onPressed: () => unawaited(
_copyToClipboard(context, imapCount, jmapCount), _copyToClipboard(context, imapCount, jmapCount),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 4),
Expanded( Expanded(
child: FilledButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.bug_report), icon: const Icon(Icons.bug_report_outlined),
label: const Text('Create issue'), label: const Text('Public issue'),
onPressed: () => unawaited( onPressed: () => unawaited(
_createIssue(context, imapCount, jmapCount), _createIssue(context, imapCount, jmapCount),
), ),
), ),
), ),
const SizedBox(width: 4),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.feedback_outlined),
label: const Text('Report bug'),
onPressed: () => context.push('/bug-report'),
),
),
], ],
), ),
), ),
+112 -71
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -66,6 +67,14 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/about')); unawaited(context.push('/accounts/about'));
}, },
), ),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/preferences'));
},
),
], ],
), ),
), ),
@@ -111,20 +120,80 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id)); final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP'; final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile( return Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Column( ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
Text('${account.email}\n$typeLabel'), subtitle: Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4), isThreeLine: true,
health.when( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) { data: (h) {
if (h == null) return const Text('Sync health: Not verified yet'); if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row( return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Sync health: '), const Text('Sync health: '),
Icon( Icon(
@@ -133,7 +202,13 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'), Expanded(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)), Text(' ($date)', style: const TextStyle(fontSize: 10)),
], ],
); );
@@ -141,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'), loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'), error: (e, _) => Text('Sync health error: $e'),
), ),
], ),
), ],
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
); );
} }
@@ -293,6 +310,30 @@ class _AccountTile extends ConsumerWidget {
} }
} }
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget { class _OnboardingView extends StatelessWidget {
const _OnboardingView(); const _OnboardingView();
+75 -13
View File
@@ -32,11 +32,15 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> { class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_Step _step = _Step.generatingKey; _Step _step = _Step.generatingKey;
ShareKeyMaterial? _keyMaterial; ShareKeyMaterial? _keyMaterial;
DateTime? _keyExpiresAt;
String? _pubKeyQr; String? _pubKeyQr;
String? _errorMessage; String? _errorMessage;
bool _scannerActive = false; bool _scannerActive = false;
MobileScannerController? _scannerController; MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override @override
void initState() { void initState() {
@@ -61,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
); );
setState(() { setState(() {
_keyMaterial = material; _keyMaterial = material;
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
_pubKeyQr = qr; _pubKeyQr = qr;
_step = _Step.showingPubKey; _step = _Step.showingPubKey;
}); });
@@ -76,8 +81,37 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
setState(() { setState(() {
_step = _Step.scanning; _step = _Step.scanning;
_scannerActive = true; _scannerActive = true;
_scannerController = MobileScannerController();
}); });
if (_cameraScanSupported()) {
unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
}
} }
Future<void> _onScanned(String rawValue) async { Future<void> _onScanned(String rawValue) async {
@@ -185,11 +219,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
), ),
), ),
_Step.done => const Center( _Step.done => const Center(
child: Icon( child: Icon(Icons.check_circle, size: 64, color: Colors.green),
Icons.check_circle,
size: 64,
color: Colors.green,
),
), ),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
@@ -244,7 +274,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const _ExpiryHint(), _ExpiryHint(expiresAt: _keyExpiresAt!),
const SizedBox(height: 32), const SizedBox(height: 32),
if (_errorMessage != null) ...[ if (_errorMessage != null) ...[
Text( Text(
@@ -266,11 +296,14 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
} }
Widget _buildScannerView(BuildContext context) { Widget _buildScannerView(BuildContext context) {
// On platforms where the camera scanner is not available (Linux desktop), // Fall back to text input when the platform has no camera support or when
// fall back to a text-input field. // the scanner plugin fails to initialise at runtime (MissingPluginException).
if (!_cameraScanSupported()) { if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context); return _buildTextFallbackView(context);
} }
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack( return Stack(
children: [ children: [
@@ -371,8 +404,37 @@ bool _cameraScanSupported() =>
Platform.isMacOS || Platform.isMacOS ||
Platform.isWindows; Platform.isWindows;
class _ExpiryHint extends StatelessWidget { class _ExpiryHint extends StatefulWidget {
const _ExpiryHint(); const _ExpiryHint({required this.expiresAt});
final DateTime expiresAt;
@override
State<_ExpiryHint> createState() => _ExpiryHintState();
}
class _ExpiryHintState extends State<_ExpiryHint> {
late Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
String _formatRemaining() {
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
final seconds = remaining.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -382,7 +444,7 @@ class _ExpiryHint extends StatelessWidget {
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]), Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'This key expires in 20 minutes', 'This key expires in ${_formatRemaining()}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
], ],
+37 -9
View File
@@ -45,12 +45,42 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
bool _scannerActive = true; bool _scannerActive = true;
MobileScannerController? _scannerController; MobileScannerController? _scannerController;
// True when the scanner plugin fails to initialise at runtime (e.g.
// MissingPluginException on some Android builds).
bool _scannerFailed = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (_cameraScanSupported()) { if (_cameraScanSupported()) {
_scannerController = MobileScannerController(); unawaited(_initScanner());
}
}
// Pre-flight: probe the scanner's permission-state method to verify the
// plugin is registered. MissingPluginException is thrown on Android builds
// where the plugin is not linked (issue #204). All other exceptions mean
// the plugin exists but something else failed — the MobileScanner widget
// will surface those via its own error builder.
Future<void> _initScanner() async {
bool available = false;
try {
await const MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
).invokeMethod<int>('state');
available = true;
} on MissingPluginException {
// Plugin not registered on this device; text fallback will be shown.
} catch (_) {
// Plugin registered but state check failed; let the scanner widget
// handle it via its errorBuilder.
available = true;
}
if (!mounted) return;
if (available) {
setState(() => _scannerController = MobileScannerController());
} else {
setState(() => _scannerFailed = true);
} }
} }
@@ -128,10 +158,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
for (final account in selected) { for (final account in selected) {
final password = await repo.getPassword(account.id); final password = await repo.getPassword(account.id);
payloads.add( payloads.add(
AccountPayload( AccountPayload(accountJson: account.toJson(), password: password),
accountJson: account.toJson(),
password: password,
),
); );
} }
@@ -178,9 +205,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
} }
Widget _buildScanStep(BuildContext context) { Widget _buildScanStep(BuildContext context) {
if (!_cameraScanSupported()) { if (!_cameraScanSupported() || _scannerFailed) {
return _buildTextFallbackView(context); return _buildTextFallbackView(context);
} }
if (_scannerController == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack( return Stack(
children: [ children: [
@@ -328,9 +358,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!))); unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text('Encrypted code copied to clipboard'),
'Encrypted code copied to clipboard',
),
), ),
); );
}, },
+635
View File
@@ -0,0 +1,635 @@
import 'dart:async';
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
const _bugReportApiUrl = String.fromEnvironment(
'BUG_REPORT_API_URL',
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
);
class BugReportScreen extends ConsumerStatefulWidget {
const BugReportScreen({super.key, this.emailId});
final String? emailId;
@override
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
}
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _emailController = TextEditingController();
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture = getDeviceModel();
final List<PlatformFile> _attachments = [];
bool _includeEmail = false;
bool _includeSyncLog = false;
bool _submitting = false;
Email? _attachedEmail;
List<Account> _accounts = [];
String? _selectedAccountId;
String? _deviceModel;
bool _loadingEmail = false;
@override
void initState() {
super.initState();
unawaited(_loadInitialData());
}
@override
void dispose() {
_descriptionController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _loadInitialData() async {
setState(() => _loadingEmail = true);
try {
_deviceModel = await _deviceModelFuture;
_accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (widget.emailId != null) {
final email =
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
if (mounted && email != null) {
_attachedEmail = email;
_selectedAccountId = email.accountId;
final fromStr =
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
final subjectStr = email.subject ?? '(no subject)';
_descriptionController.text =
'Problem with email from $fromStr: "$subjectStr"\n\n';
}
}
if (_selectedAccountId == null && _accounts.isNotEmpty) {
_selectedAccountId = _accounts.first.id;
}
if (_selectedAccountId != null) {
final matching =
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
if (matching != null) {
_emailController.text = matching.email;
}
}
} catch (_) {}
if (mounted) {
setState(() => _loadingEmail = false);
}
}
int get _totalAttachmentSize {
return _attachments.fold(0, (sum, f) => sum + f.size);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
Future<void> _pickAttachments() async {
try {
final result = await FilePicker.pickFiles();
if (result == null) return;
final newFiles =
result.files.where((PlatformFile f) => f.path != null).toList();
if (!mounted) return;
setState(() {
_attachments.addAll(newFiles);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick files: $e')),
);
}
}
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
}
String _serializeSyncLogs(List<SyncLogEntry> entries) {
final sb = StringBuffer();
for (final entry in entries.take(50)) {
sb.writeln('ID: ${entry.id}');
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
sb.writeln('Result: ${entry.result}');
if (entry.errorMessage != null) {
sb.writeln('Error: ${entry.errorMessage}');
}
if (entry.stackTrace != null) {
sb.writeln('StackTrace:\n${entry.stackTrace}');
}
sb.writeln('Protocol: ${entry.protocol}');
sb.writeln(
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
);
if (entry.protocolLog != null) {
sb.writeln('Protocol Log:\n${entry.protocolLog}');
}
sb.writeln('---');
}
return sb.toString();
}
Future<void> _submitReport() async {
if (!_formKey.currentState!.validate()) return;
final totalSize = _totalAttachmentSize;
if (totalSize > 20 * 1024 * 1024) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
),
backgroundColor: Colors.red,
),
);
return;
}
setState(() => _submitting = true);
try {
final client = ref.read(httpClientProvider);
final uri = Uri.parse(_bugReportApiUrl);
final request = http.MultipartRequest('POST', uri);
// Description
request.fields['description'] = _descriptionController.text;
// Email Data if from email view
if (_attachedEmail != null) {
final emailMap = {
'id': _attachedEmail!.id,
'subject': _attachedEmail!.subject,
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
'date': _attachedEmail!.sentAt?.toIso8601String() ??
_attachedEmail!.receivedAt.toIso8601String(),
'preview': _attachedEmail!.preview,
};
request.fields['email_data'] = jsonEncode(emailMap);
}
// Contact Email
if (_includeEmail) {
request.fields['email'] = _emailController.text;
}
// About Info
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
final imapCount =
_accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount =
_accounts.where((a) => a.type == AccountType.jmap).length;
if (!mounted) return;
final aboutInfo = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
request.fields['about_info'] = aboutInfo;
// Sync Log
if (_includeSyncLog && _selectedAccountId != null) {
final syncLogs = await ref
.read(syncLogRepositoryProvider)
.observeSyncLogs(_selectedAccountId!)
.first;
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
}
// Attachments
for (final file in _attachments) {
final multipartFile = await http.MultipartFile.fromPath(
'attachments[]',
file.path!,
filename: file.name,
);
request.files.add(multipartFile);
}
final streamedResponse = await client.send(request);
final response = await http.Response.fromStream(streamedResponse);
if (!mounted) return;
if (response.statusCode == 201) {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
final reportId = resData['id'] as String;
_showSuccessDialog(reportId);
} else if (response.statusCode == 429) {
final retryAfter = response.headers['retry-after'] ?? '6';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
backgroundColor: Colors.orange,
),
);
} else {
String errorMsg =
'Failed to submit report. Server returned status: ${response.statusCode}';
try {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
if (resData['error'] != null) {
errorMsg = resData['error'] as String;
}
} catch (_) {}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An error occurred: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
void _showSuccessDialog(String reportId) {
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Bug Report Submitted'),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('Thank you for helping us improve SharedInbox!'),
const SizedBox(height: 12),
Text(
'Your Report ID is:\n$reportId',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your report is handled confidentially and has not been posted to the public issue tracker.',
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Dismiss dialog
context.pop(); // Go back to previous screen
},
child: const Text('Close'),
),
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalSize = _totalAttachmentSize;
const sizeLimit = 20 * 1024 * 1024;
final approachingLimit = totalSize > 15 * 1024 * 1024;
return Scaffold(
appBar: AppBar(
title: const Text('Report a Bug'),
),
body: _loadingEmail
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Confidentiality info card
Card(
elevation: 0,
color: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
side: BorderSide(
color:
theme.colorScheme.secondary.withValues(alpha: 0.4),
),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.lock_outline,
color: theme.colorScheme.secondary,
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Your report is handled confidentially and will not be posted to the public issue tracker.',
style: TextStyle(height: 1.3),
),
),
],
),
),
),
const SizedBox(height: 20),
// Description Text Field
TextFormField(
controller: _descriptionController,
autofocus: true,
maxLines: 8,
minLines: 4,
decoration: const InputDecoration(
labelText: 'What went wrong?',
alignLabelWithHint: true,
border: OutlineInputBorder(),
helperText:
'Please describe the problem and how to reproduce it.',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a description.';
}
return null;
},
),
const SizedBox(height: 20),
// Email info chip if email is attached
if (_attachedEmail != null) ...[
Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: Row(
children: [
Icon(
Icons.email_outlined,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'The current email metadata will be attached automatically.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Attachments Section
Text(
'Attachments',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickAttachments,
icon: const Icon(Icons.add_a_photo_outlined),
label: const Text('Add screenshots'),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Screenshots help us understand the problem faster.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
),
if (_attachments.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _attachments.length,
itemBuilder: (context, index) {
final file = _attachments[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: InputChip(
label: Text(
'${file.name} (${_formatSize(file.size)})',
),
onDeleted: _submitting
? null
: () => _removeAttachment(index),
),
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
style: TextStyle(
fontSize: 12,
color: totalSize > sizeLimit
? Colors.red
: approachingLimit
? Colors.orange
: Colors.grey,
fontWeight: approachingLimit
? FontWeight.bold
: FontWeight.normal,
),
),
if (totalSize > sizeLimit) ...[
const SizedBox(width: 8),
const Icon(
Icons.error_outline,
size: 16,
color: Colors.red,
),
],
],
),
],
const SizedBox(height: 24),
// Email opt-in
CheckboxListTile(
title: const Text('Include my email for follow-up'),
value: _includeEmail,
onChanged: _submitting
? null
: (val) {
setState(() => _includeEmail = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
if (_includeEmail) ...[
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Contact Email Address',
border: OutlineInputBorder(),
),
validator: (value) {
if (_includeEmail &&
(value == null || value.trim().isEmpty)) {
return 'Please enter an email address.';
}
return null;
},
),
),
],
// Sync log opt-in
if (_selectedAccountId != null) ...[
CheckboxListTile(
title: const Text('Include recent sync log'),
subtitle: const Text(
'Helps diagnose connection and protocol issues.',
),
value: _includeSyncLog,
onChanged: _submitting
? null
: (val) {
setState(() => _includeSyncLog = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 12),
],
// System info section
FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
final imapCount = _accounts
.where((a) => a.type == AccountType.imap)
.length;
final jmapCount = _accounts
.where((a) => a.type == AccountType.jmap)
.length;
final aboutMd = buildAboutMarkdown(
context: context,
pkg: snapshot.data,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.1),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpansionTile(
title: const Text(
'System Info (attached automatically)',
style: TextStyle(fontSize: 14),
),
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Align(
alignment: Alignment.topLeft,
child: MarkdownBody(data: aboutMd),
),
),
],
),
);
},
),
const SizedBox(height: 32),
// Submit Button
FilledButton(
onPressed: _submitting ? null : _submitReport,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Send Bug Report',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
}
+80 -7
View File
@@ -1,21 +1,91 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class ChangeLogScreen extends StatelessWidget { class ChangeLogScreen extends ConsumerWidget {
const ChangeLogScreen({super.key}); const ChangeLogScreen({super.key});
static const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
static String _formatInstallDate(DateTime dt) {
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
final month = _months[dt.month - 1];
return '$h:$m, ${dt.day} $month ${dt.year}';
}
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
static final _issueRefPattern = RegExp(r'#(\d+)');
static String _linkifyIssueRefs(String text) {
return text.replaceAllMapped(
_issueRefPattern,
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
);
}
// Changelog lines have the form:
// * 2026-06-05 [abc1234](https://...): subject
// This pattern captures the short hash inside the markdown link.
static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\(');
static String _injectInstallMarkers(
String changelog,
Map<String, DateTime> versions,
) {
if (versions.isEmpty) return changelog;
final lines = changelog.split('\n');
final buf = StringBuffer();
for (final line in lines) {
final match = _hashPattern.firstMatch(line);
if (match != null) {
final lineHash = match.group(1)!;
for (final entry in versions.entries) {
final stored = entry.key;
final matches = stored == lineHash ||
stored.startsWith(lineHash) ||
lineHash.startsWith(stored);
if (!matches) continue;
buf.write(
'\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n',
);
break;
}
}
buf.writeln(line);
}
return buf.toString();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final installedVersions = ref.watch(installedVersionsProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')), appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: rootBundle.loadString('assets/changelog.txt'), future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting ||
installedVersions.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (snapshot.hasError) { if (snapshot.hasError) {
@@ -23,9 +93,12 @@ class ChangeLogScreen extends StatelessWidget {
child: Text('Error loading changelog: ${snapshot.error}'), child: Text('Error loading changelog: ${snapshot.error}'),
); );
} }
final content = snapshot.data ?? 'No changelog entries found.'; final raw = snapshot.data ?? 'No changelog entries found.';
final content = _linkifyIssueRefs(raw);
final versions = installedVersions.value ?? {};
final annotated = _injectInstallMarkers(content, versions);
return Markdown( return Markdown(
data: content, data: annotated,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited( unawaited(
+422
View File
@@ -0,0 +1,422 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@override
ConsumerState<CombinedInboxScreen> createState() =>
_CombinedInboxScreenState();
}
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
List<EmailThread> _currentThreads = [];
bool get _selecting => _selectedThreadIds.isNotEmpty;
void _toggleThreadSelection(EmailThread thread) {
setState(() {
if (_selectedThreadIds.contains(thread.threadId)) {
_selectedThreadIds.remove(thread.threadId);
} else {
_selectedThreadIds.add(thread.threadId);
}
});
}
void _clearSelection() => setState(() => _selectedThreadIds.clear());
void _selectAll() {
setState(
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
);
}
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
return accountsAsync.when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
body: Center(child: Text('Error: $e')),
),
data: (accounts) {
if (accounts.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) context.go('/accounts');
});
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final accountNames = {
for (final a in accounts) a.id: a.displayName,
};
final showAccount = accounts.length > 1;
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _selecting ? null : _buildDrawer(context, accounts),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: _buildBody(accountNames, showAccount),
floatingActionButton: _selecting
? null
: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
if (_selecting) {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text('${_selectedThreadIds.length} selected'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: 'Select all',
onPressed: _selectAll,
),
],
);
}
return AppBar(
title: const Text('Combined Inbox'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync all',
onPressed: () {
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
),
],
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: _batchArchive,
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: _batchDelete,
),
],
),
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text(
'sharedinbox.de',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
ListTile(
leading: const Icon(Icons.manage_accounts),
title: const Text('Accounts'),
onTap: () {
Navigator.pop(context);
context.go('/accounts');
},
),
ListTile(
leading: const Icon(Icons.person_add),
title: const Text('Add account'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/add'));
},
),
const Divider(),
for (final account in accounts)
ListTile(
leading: const Icon(Icons.inbox),
title: Text(account.displayName),
subtitle: Text(account.email),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/${account.id}/mailboxes'));
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/preferences'));
},
),
ListTile(
leading: const Icon(Icons.history),
title: const Text('Undo Log'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/undo-log'));
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/about'));
},
),
],
),
);
}
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
final emailRepo = ref.watch(emailRepositoryProvider);
return RefreshIndicator(
onRefresh: () async {
final accounts = ref.read(allAccountsProvider).value ?? [];
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeAllInboxThreads(limit: _limit),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
_currentThreads = threads;
if (threads.isEmpty) {
return ListView(
children: const [
SizedBox(
height: 300,
child: Center(child: Text('No emails')),
),
],
);
}
return _buildThreadList(threads, accountNames, showAccount);
},
),
);
}
Widget _buildThreadList(
List<EmailThread> threads,
Map<String, String> accountNames,
bool showAccount,
) {
final hasMore = threads.length == _limit;
return ListView.builder(
itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
final t = threads[i];
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
showAccount: showAccount,
accountName: accountNames[t.accountId],
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
},
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(t.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() async {
final repo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
// Group selected threads by accountId so we look up each account's archive once.
final byAccount = <String, List<EmailThread>>{};
for (final t in _currentThreads) {
if (!_selectedThreadIds.contains(t.threadId)) continue;
(byAccount[t.accountId] ??= []).add(t);
}
_clearSelection();
for (final entry in byAccount.entries) {
final accountId = entry.key;
final threads = entry.value;
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
if (!mounted || archive == null) continue;
for (final t in threads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
}
}
Future<void> _batchDelete() async {
final repo = ref.read(emailRepositoryProvider);
final selectedThreads = _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.toList();
_clearSelection();
for (final t in selectedThreads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
}
}
+3 -9
View File
@@ -194,9 +194,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Failed to open file: $e'), content: Text('Failed to open file: $e'),
@@ -213,9 +211,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Select an account first'), content: Text('Select an account first'),
@@ -255,9 +251,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Send failed: $e'), content: Text('Send failed: $e'),
+90 -40
View File
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -10,27 +11,45 @@ class CrashScreen extends StatelessWidget {
super.key, super.key,
required this.exception, required this.exception,
required this.stackTrace, required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
}); });
final Object exception; final Object exception;
final StackTrace? stackTrace; final StackTrace? stackTrace;
final String gitHash;
static const _gitHash = String.fromEnvironment('GIT_HASH'); String get _buildMode {
if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _buildReport() async { Future<String> _fetchVersion() async {
String version = 'unknown';
try { try {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
version = '${info.version}+${info.buildNumber}'; return '${info.version}+${info.buildNumber}';
} catch (_) {} } catch (_) {
return 'unknown';
}
}
Future<String> _buildReport() async {
final version = await _fetchVersion();
final platform = final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final gitLine = _gitHash.isNotEmpty final versionDisplay = gitHash.isNotEmpty
? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n' ? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
: version;
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: ''; : '';
return 'App Version: $version\n' final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n'
'Build Mode: $_buildMode\n'
'$gitLine' '$gitLine'
'Platform: $platform\n\n' 'Platform: $platform\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
'Error:\n```\n$exception\n```\n\n' 'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```'; 'Stack Trace:\n```\n$stackTrace\n```';
} }
@@ -38,6 +57,7 @@ class CrashScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: Scaffold( home: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Something went wrong'), title: const Text('Something went wrong'),
@@ -56,13 +76,69 @@ class CrashScreen extends StatelessWidget {
style: Theme.of(ctx).textTheme.titleMedium, style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (_gitHash.isNotEmpty) ...[ const SizedBox(height: 4),
const SizedBox(height: 8), FutureBuilder<String>(
const Text( future: _fetchVersion(),
'Git Commit: $_gitHash', builder: (context, snapshot) => Text(
style: TextStyle(fontSize: 12, color: Colors.grey), 'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final version =
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
return GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'App Version: $version',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
},
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'Git Commit: $gitHash',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
),
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
@@ -106,32 +182,6 @@ class CrashScreen extends StatelessWidget {
), ),
), ),
], ],
if (_gitHash.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Git Commit:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: const Text(
_gitHash,
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
],
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.icon( FilledButton.icon(
onPressed: () async { onPressed: () async {
+21 -4
View File
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
var _sieveSsl = true; var _sieveSsl = true;
var _verbose = false; var _verbose = false;
final _jmapUrlCtrl = TextEditingController(); final _jmapUrlCtrl = TextEditingController();
bool _hasStoredPassword = false;
// -- "Try connection" state ------------------------------------------------ // -- "Try connection" state ------------------------------------------------
bool _tryTesting = false; bool _tryTesting = false;
@@ -50,6 +51,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild); _smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild); _sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild); _imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load()); unawaited(_load());
} }
@@ -63,6 +65,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
context.pop(); context.pop();
return; return;
} }
try {
await repo.getPassword(account.id);
_hasStoredPassword = true;
} catch (_) {}
if (!mounted) return;
_account = account; _account = account;
_displayNameCtrl.text = account.displayName; _displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username; _usernameCtrl.text = account.username;
@@ -84,6 +91,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild); _smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild); _sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild); _imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [ for (final c in [
_displayNameCtrl, _displayNameCtrl,
_usernameCtrl, _usernameCtrl,
@@ -267,10 +275,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
), ),
_field( _field(
_passwordCtrl, _passwordCtrl,
'New password (leave blank to keep)', _hasStoredPassword
? 'New password (leave blank to keep)'
: 'Password',
key: const Key('editPasswordField'), key: const Key('editPasswordField'),
obscure: true, obscure: true,
required: false, required: !_hasStoredPassword,
), ),
if (account.type == AccountType.jmap) ...[ if (account.type == AccountType.jmap) ...[
const Divider(height: 32), const Divider(height: 32),
@@ -345,10 +355,17 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting, testing: _tryTesting,
okMessage: _tryOk, okMessage: _tryOk,
errorMessage: _tryErr, errorMessage: _tryErr,
onPressed: _tryConnection, onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _tryConnection
: null,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton(onPressed: _save, child: const Text('Save')), FilledButton(
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _save
: null,
child: const Text('Save'),
),
], ],
), ),
), ),
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m in mailboxes.where(
(m) => m.path != currentMailboxPath,
))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+568 -137
View File
@@ -12,10 +12,15 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -34,6 +39,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
bool _isFlagged = false; bool _isFlagged = false;
bool _loadRemoteImages = false; bool _loadRemoteImages = false;
final Set<String> _downloading = {}; final Set<String> _downloading = {};
bool _notesSynced = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -47,6 +53,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (email != null && mounted) { if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged); setState(() => _isFlagged = email.isFlagged);
} }
if (!_notesSynced && email?.messageId != null) {
_notesSynced = true;
unawaited(
ref.read(noteRepositoryProvider).syncNotes(
email!.accountId,
email.messageId!,
),
);
}
}, },
); );
@@ -59,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !isMobile, automaticallyImplyLeading: !isMobile,
title: Text(
header?.subject ?? '(loading…)',
overflow: TextOverflow.ellipsis,
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.reply), icon: const Icon(Icons.reply),
@@ -70,33 +81,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_reply(context, header, body, replyAll: false)); unawaited(_replyWithRecipientDialog(context, header, body));
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.reply_all), icon: const Icon(Icons.archive),
tooltip: 'Reply all', tooltip: 'Archive',
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_reply(context, header, body, replyAll: true)); unawaited(_archive(context, header));
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.forward), icon: const Icon(Icons.delete),
tooltip: 'Forward', tooltip: 'Delete',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
tooltip: 'Mark as unread',
onPressed: () async { onPressed: () async {
await repo.setFlag(widget.emailId, seen: false); final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (context.mounted) context.pop(); final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
await ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
),
);
}
if (context.mounted) _navigateTo(context, header, nextEmailId);
}, },
), ),
IconButton( IconButton(
@@ -112,42 +130,24 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.drive_file_move_outline), icon: const Icon(Icons.report_outlined),
tooltip: 'Move to folder', tooltip: 'Mark as spam',
onPressed: header == null ? null : () => _moveTo(context, header), onPressed: header == null
), ? null
IconButton( : () {
icon: const Icon(Icons.access_time), unawaited(_markAsSpam(context, header));
tooltip: 'Snooze', },
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () async {
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
),
),
);
}
if (context.mounted) context.pop();
},
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(value: 'forward', child: Text('Forward')),
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -156,18 +156,34 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure', value: 'structure',
child: Text('Show Mail Structure'), child: Text('Show Mail Structure'),
), ),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'rfc', value: 'bug_report',
child: Text('Show Raw Email'), child: Text('Report a Bug'),
), ),
], ],
onSelected: (value) { onSelected: (value) async {
if (value == 'headers' && body != null) { if (value == 'forward' && header != null) {
unawaited(_forward(context, header, body));
} else if (value == 'move' && header != null) {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
_showStructure(context, body); _showStructure(context, body);
} else if (value == 'rfc') { } else if (value == 'rfc') {
unawaited(_showRaw(context, header)); unawaited(_showRaw(context, header));
} else if (value == 'bug_report') {
unawaited(
context.push('/bug-report?emailId=${widget.emailId}'),
);
} }
}, },
), ),
@@ -176,19 +192,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body: detail.when( body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')), error: (e, _) => Center(child: Text('Error: $e')),
data: (d) => _buildBody(context, d.$1, d.$2), data: (d) {
final trusted =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
return _buildBody(context, d.$1, d.$2, trusted);
},
), ),
); );
} }
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) { Widget _buildBody(
BuildContext ctx,
Email? header,
EmailBody body,
List<String> trustedSenders,
) {
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final senderEmail = header?.from.isNotEmpty == true
? header!.from.first.email.toLowerCase()
: null;
final isTrusted = senderEmail != null &&
trustedSenders.any((p) => globMatch(senderEmail, p));
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (header != null) ...[_buildHeader(ctx, header), const Divider()], if (header != null) ...[_buildHeader(ctx, header), const Divider()],
if (hasHtml) ...[ if (hasHtml) ...[
if (!_loadRemoteImages) if (!effectiveLoadImages)
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
@@ -196,19 +228,50 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.image_outlined, size: 18), icon: const Icon(Icons.image_outlined, size: 18),
label: const Text('Load remote images'), label: const Text('Load remote images'),
onPressed: () => setState(() => _loadRemoteImages = true), onPressed: () {
setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
),
),
);
}
},
), ),
), ),
), ),
SecureEmailWebView( SecureEmailWebView(
htmlBody: body.htmlBody!, htmlBody: body.htmlBody!,
loadRemoteImages: _loadRemoteImages, loadRemoteImages: effectiveLoadImages,
), ),
] else ] else
SelectableText( SelectableText(
body.textBody ?? '', body.textBody ?? '',
style: Theme.of(ctx).textTheme.bodyMedium, style: Theme.of(ctx).textTheme.bodyMedium,
), ),
if (header?.messageId != null) _buildNotesSection(ctx, header!),
if (body.attachments.isNotEmpty) ...[ if (body.attachments.isNotEmpty) ...[
const Divider(), const Divider(),
Padding( Padding(
@@ -241,6 +304,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(widget.emailId),
);
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async { Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename)); setState(() => _downloading.add(att.filename));
try { try {
@@ -258,6 +355,114 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
} }
Widget _buildNotesSection(BuildContext ctx, Email header) {
final messageId = header.messageId!;
final notes = ref.watch(notesProvider((header.accountId, messageId)));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'Notes',
style: Theme.of(ctx).textTheme.titleSmall,
),
),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('Add'),
onPressed: () => unawaited(_addNoteDialog(ctx, header)),
),
],
),
notes.when(
loading: () => const SizedBox.shrink(),
error: (e, _) => Text('Error loading notes: $e'),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text(
'No notes yet.',
style: TextStyle(color: Colors.grey),
),
);
}
return Column(
children: [
for (final note in list) _buildNoteRow(ctx, note),
],
);
},
),
],
);
}
Widget _buildNoteRow(BuildContext ctx, EmailNote note) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(note.noteText),
subtitle: Text(
DateFormat('MMM d, HH:mm').format(note.createdAt),
style: Theme.of(ctx).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Delete note',
onPressed: () {
unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id));
},
),
);
}
Future<void> _addNoteDialog(BuildContext context, Email header) async {
final messageId = header.messageId;
if (messageId == null) return;
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Add note'),
content: TextField(
controller: ctrl,
autofocus: true,
maxLines: 4,
decoration: const InputDecoration(hintText: 'Type a note…'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Save'),
),
],
),
);
final text = ctrl.text.trim();
ctrl.dispose();
if (confirmed != true || text.isEmpty) return;
if (!context.mounted) return;
await ref.read(noteRepositoryProvider).addNote(
header.accountId,
messageId,
text,
);
}
Widget _buildHeader(BuildContext ctx, Email email) { Widget _buildHeader(BuildContext ctx, Email email) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -303,17 +508,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return '\n\n— On $date, $from wrote:\n$quoted'; return '\n\n— On $date, $from wrote:\n$quoted';
} }
Future<void> _reply( Future<void> _replyWithRecipientDialog(
BuildContext context,
Email header,
EmailBody? body,
) async {
final account =
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
final ownEmail = account?.email.toLowerCase() ?? '';
final seen = <String>{};
final candidates = <_Candidate>[];
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
final key = addr.email.toLowerCase();
if (key == ownEmail || seen.contains(key)) return;
seen.add(key);
candidates.add(_Candidate(addr, defaultPlacement));
}
for (final addr in header.from) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.to) {
addIfNew(addr, _Placement.to);
}
for (final addr in header.cc) {
addIfNew(addr, _Placement.cc);
}
if (!context.mounted) return;
if (candidates.length <= 1) {
final to = candidates
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = candidates
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
return;
}
final confirmed = await showDialog<List<_Candidate>>(
context: context,
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
);
if (confirmed == null || !context.mounted) return;
final to = confirmed
.where((c) => c.placement == _Placement.to)
.map((c) => c.address.email)
.join(', ');
final cc = confirmed
.where((c) => c.placement == _Placement.cc)
.map((c) => c.address.email)
.join(', ');
await _composeReply(context, header, body, to: to, cc: cc);
}
Future<void> _composeReply(
BuildContext context, BuildContext context,
Email header, Email header,
EmailBody? body, { EmailBody? body, {
required bool replyAll, required String to,
required String cc,
}) async { }) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false) final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject! ? header.subject!
: 'Re: ${header.subject ?? ''}'; : 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body); final quoted = await _quotedBody(header, body);
if (!context.mounted) return; if (!context.mounted) return;
unawaited( unawaited(
@@ -330,6 +596,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<void> _archive(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _forward( Future<void> _forward(
BuildContext context, BuildContext context,
Email header, Email header,
@@ -343,15 +681,50 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: { extra: {'prefillSubject': subject, 'prefillBody': quoted},
'prefillSubject': subject,
'prefillBody': quoted,
},
), ),
); );
} }
Future<String?> _promptNewFolderName(BuildContext context) async {
final controller = TextEditingController();
try {
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Create new folder'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'Folder name'),
textCapitalization: TextCapitalization.words,
onSubmitted: (value) {
if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim());
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.pop(ctx, name);
},
child: const Text('Create'),
),
],
),
);
} finally {
controller.dispose();
}
}
Future<void> _moveTo(BuildContext context, Email header) async { Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first; await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -362,6 +735,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return; if (!context.mounted) return;
const createNewSentinel = '__create_new__';
final chosen = await showModalBottomSheet<String>( final chosen = await showModalBottomSheet<String>(
context: context, context: context,
builder: (ctx) => ListView( builder: (ctx) => ListView(
@@ -379,13 +754,28 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
title: Text(m.name), title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path), onTap: () => Navigator.pop(ctx, m.path),
), ),
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('Create new folder…'),
onTap: () => Navigator.pop(ctx, createNewSentinel),
),
], ],
), ),
); );
if (chosen == null || !context.mounted) return; if (chosen == null || !context.mounted) return;
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen); String destination = chosen;
if (chosen == createNewSentinel) {
final name = await _promptNewFolderName(context);
if (name == null || !context.mounted) return;
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
destination = mailbox.path;
}
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, destination);
unawaited( unawaited(
ref.read(undoServiceProvider.notifier).pushAction( ref.read(undoServiceProvider.notifier).pushAction(
@@ -395,15 +785,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move, type: UndoType.move,
emailIds: [widget.emailId], emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath, sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: chosen, destinationMailboxPath: destination,
), ),
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _snooze(BuildContext context, Email header) async { Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>( final until = await showModalBottomSheet<DateTime>(
context: context, context: context,
builder: (ctx) => const SnoozePicker(), builder: (ctx) => const SnoozePicker(),
@@ -431,7 +824,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
); );
context.pop(); _navigateTo(context, header, nextEmailId);
} }
} }
@@ -443,9 +836,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.fetchRawRfc822(widget.emailId); .fetchRawRfc822(widget.emailId);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to fetch raw email: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
return; return;
} }
@@ -559,47 +952,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => EmailHeadersDialog(headers: body.headers),
title: const Text('Mail Headers'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: body.headers.length,
itemBuilder: (ctx, i) {
final header = body.headers[i];
return Container(
color: i.isEven
? Theme.of(ctx).colorScheme.surfaceContainerHighest
: Theme.of(ctx).colorScheme.surface,
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
), ),
); );
} }
@@ -610,9 +963,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text( content: Text('Structure not available. Try re-syncing the email.'),
'Structure not available. Try re-syncing the email.',
),
), ),
); );
return; return;
@@ -624,12 +975,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => Dialog.fullscreen(
title: const Text('Mail Structure'), child: Scaffold(
content: SizedBox( appBar: AppBar(
width: double.maxFinite, title: const Text('Mail Structure'),
child: ListView.builder( leading: const CloseButton(),
shrinkWrap: true, ),
body: ListView.builder(
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final row = rows[i]; final row = rows[i];
@@ -658,14 +1010,90 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}, },
), ),
), ),
actions: [ ),
TextButton( ),
onPressed: () => Navigator.pop(ctx), );
child: const Text('Close'), }
), }
enum _Placement { to, cc, skip }
class _Candidate {
_Candidate(this.address, this.placement);
final EmailAddress address;
_Placement placement;
}
class _ReplyAllDialog extends StatefulWidget {
const _ReplyAllDialog({required this.candidates});
final List<_Candidate> candidates;
@override
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
}
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
late final List<_Candidate> _candidates;
@override
void initState() {
super.initState();
_candidates = [
for (final c in widget.candidates) _Candidate(c.address, c.placement),
];
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Reply All'),
content: SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
children: [
for (final c in _candidates)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
c.address.toString(),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
SegmentedButton<_Placement>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: _Placement.to, label: Text('To')),
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
ButtonSegment(
value: _Placement.skip,
label: Text('Skip'),
),
],
selected: {c.placement},
onSelectionChanged: (s) =>
setState(() => c.placement = s.first),
),
],
),
),
], ],
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _candidates),
child: const Text('Reply'),
),
],
); );
} }
} }
@@ -712,10 +1140,13 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header); final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink(); if (uri == null) return const SizedBox.shrink();
return ActionChip( return Tooltip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16), message: uri.toString(),
label: const Text('Unsubscribe'), child: ActionChip(
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
); );
} }
} }
+171 -223
View File
@@ -8,21 +8,14 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day so DateFormat.format is called
// at most once per unique date rather than once per list item per rebuild.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class EmailListScreen extends ConsumerStatefulWidget { class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({ const EmailListScreen({
@@ -57,6 +50,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Pagination: number of threads currently requested from the DB. // Pagination: number of threads currently requested from the DB.
static const _pageSize = 50; static const _pageSize = 50;
int _limit = _pageSize; int _limit = _pageSize;
// Incremented on every search start; stale completions are ignored when the
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
// The query whose results are currently settled in _searchResults.
// Used to skip redundant re-runs when the user presses Enter on an
// already-settled search (issue #473).
String? _lastSettledQuery;
bool get _selecting => bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty; _selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -68,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() { setState(() {
_searchResults = null; _searchResults = null;
_searchLoading = false; _searchLoading = false;
_lastSettledQuery = null;
}); });
} }
}); });
@@ -124,18 +127,35 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
Future<void> _runSearch(String query) async { Future<void> _runSearch(String query) async {
if (query.trim().isEmpty) { final q = query.trim();
setState(() => _searchResults = null); if (q.isEmpty) {
setState(() {
_searchResults = null;
_lastSettledQuery = null;
});
return; return;
} }
// Skip if results are already settled for this exact query — prevents the
// Enter key from re-triggering a search that already completed.
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
return;
}
final generation = ++_searchGeneration;
setState(() => _searchLoading = true); setState(() => _searchLoading = true);
try { try {
final results = await ref final results = await ref
.read(emailRepositoryProvider) .read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, query.trim()); .searchEmails(widget.accountId, widget.mailboxPath, q);
if (mounted) setState(() => _searchResults = results); if (mounted && generation == _searchGeneration) {
setState(() {
_searchResults = results;
_lastSettledQuery = q;
});
}
} finally { } finally {
if (mounted) setState(() => _searchLoading = false); if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
} }
} }
@@ -147,16 +167,21 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: _buildAppBar(repo, accountAsync), appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
drawer: _selecting drawer: _selecting
? null ? null
: FolderDrawer( : FolderDrawer(
accountId: widget.accountId, accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath, currentMailboxPath: widget.mailboxPath,
), ),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null, bottomNavigationBar: _selecting
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
body: Column( body: Column(
children: [ children: [
_buildSyncErrorBanner(), _buildSyncErrorBanner(),
@@ -172,12 +197,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar( PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo, EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, AsyncValue<Account?> accountAsync, {
) { required bool menuAtBottom,
}) {
final selectionCount = final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length; _searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar( return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting leading: _selecting
? IconButton( ? IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -251,7 +278,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
), ),
], ],
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
onSubmitted: _runSearch, onSubmitted: (value) {
// Only run the search if results haven't settled yet via
// onChanged — prevents a second IMAP round-trip from reordering
// the already-visible results when the user presses Enter.
if (_searchResults == null && !_searchLoading) {
unawaited(_runSearch(value));
}
},
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
), ),
), ),
@@ -300,6 +334,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
@@ -356,11 +406,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
return MaterialBanner( return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text( content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon( leading: Icon(
Icons.sync_problem, Icons.sync_problem,
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
@@ -374,9 +420,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: const Text('Retry'), child: const Text('Retry'),
), ),
TextButton( TextButton(
onPressed: () => context.push( onPressed: () =>
'/accounts/${widget.accountId}/sync-log', context.push('/accounts/${widget.accountId}/sync-log'),
),
child: const Text('View log'), child: const Text('View log'),
), ),
TextButton( TextButton(
@@ -420,24 +465,26 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Future<void> _batchMoveToRole(String role, String notFoundMessage) async { Future<void> _batchMoveToRole(
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
_clearSelection(); _clearSelection();
final mailbox = await ref
.read(mailboxRepositoryProvider) final mailbox = await resolveMailboxByRole(
.findMailboxByRole(widget.accountId, role); context,
if (!mounted) return; ref.read(mailboxRepositoryProvider),
if (mailbox == null) { widget.accountId,
ScaffoldMessenger.of( widget.mailboxPath,
context, role,
).showSnackBar( dialogTitle: dialogTitle,
SnackBar( createFolderName: createFolderName,
duration: const Duration(seconds: 5), );
content: Text(notFoundMessage),
), if (!mounted || mailbox == null) return;
);
return;
}
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -463,8 +510,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
} }
Future<void> _batchArchive() => Future<void> _batchArchive() => _batchMoveToRole(
_batchMoveToRole('archive', 'No archive folder found'); 'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _refreshSearchAndPopIfEmpty() async { Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return; if (!mounted || !_searching) return;
@@ -525,8 +575,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
if (wasSearching && mounted) { if (wasSearching && mounted) {
// Filter deleted emails out of the local results immediately. // Filter deleted emails out of the local results immediately.
// Calling searchEmails here would hit the IMAP server, which still has // Calling searchEmails here would still return deleted rows because the
// the emails because the delete is only enqueued — not yet applied. // delete is only enqueued — not yet applied to the local DB.
final deletedIds = ids.toSet(); final deletedIds = ids.toSet();
final remaining = (_searchResults ?? []) final remaining = (_searchResults ?? [])
.where((e) => !deletedIds.contains(e.id)) .where((e) => !deletedIds.contains(e.id))
@@ -543,8 +593,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
} }
Future<void> _batchMarkSpam() => Future<void> _batchMarkSpam() => _batchMoveToRole(
_batchMoveToRole('junk', 'No spam folder found'); 'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMove() async { Future<void> _batchMove() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
@@ -660,177 +713,93 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
final t = threads[i]; final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId); return EmailThreadTile(
final senderNames = thread: t,
t.participants.map((a) => a.name ?? a.email).take(3).join(', '); isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
final tile = ListTile(
leading: SizedBox(
width: 40,
child: _selecting
? Checkbox(
value: isSelected,
onChanged: (_) => _toggleThreadSelection(t),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: _selecting onTap: _selecting
? () => _toggleThreadSelection(t) ? () => _toggleThreadSelection(t)
: t.messageCount > 1 : t.messageCount > 1
? () => context.push( ? () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}', '/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
) )
: () => context.push( : () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}', '/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
), ),
onLongPress: () => _toggleThreadSelection(t), onLongPress: () => _toggleThreadSelection(t),
); onDismissed: (direction) => _onSwipeDismissed(t, direction),
// For swipe actions on threads, operate on the latest email only
// (single-email threads) or the whole thread.
return Dismissible(
key: ValueKey(t.threadId),
direction:
_selecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
} else {
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
}
},
child: tile,
); );
}, },
); );
} }
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
// Used for search results, which are individual emails. // Used for search results, which are individual emails.
Widget _buildEmailList(List<Email> emails) { Widget _buildEmailList(List<Email> emails) {
return ListView.builder( return ListView.builder(
itemCount: emails.length, itemCount: emails.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final e = emails[i]; final e = emails[i];
final t = EmailThread.fromEmail(e);
final isSelected = _selectedSearchIds.contains(e.id); final isSelected = _selectedSearchIds.contains(e.id);
return EmailTile( return ThreadTile(
email: e, thread: t,
selected: isSelected, selected: isSelected,
leading: SizedBox( leading: SizedBox(
width: 40, width: 40,
@@ -849,25 +818,4 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}, },
); );
} }
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
} }
+20
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -17,8 +18,12 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider); final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider); final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId)); final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'), title: const Text('Folders'),
actions: [ actions: [
IconButton( IconButton(
@@ -42,6 +47,21 @@ class MailboxListScreen extends ConsumerWidget {
], ],
), ),
drawer: FolderDrawer(accountId: accountId), drawer: FolderDrawer(accountId: accountId),
bottomNavigationBar: menuAtBottom
? BottomAppBar(
child: Row(
children: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
),
],
),
)
: null,
body: Column( body: Column(
children: [ children: [
// ── Failed-mutation banner ─────────────────────────────────────── // ── Failed-mutation banner ───────────────────────────────────────
+113 -17
View File
@@ -4,14 +4,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/filter_builder.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _searchHistoryProvider = final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
FutureProvider.autoDispose<List<String>>((ref) async { ref,
) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches(); return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
}); });
@@ -36,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
bool _loading = false; bool _loading = false;
bool _fieldFocused = false; bool _fieldFocused = false;
// Advanced (structured) search state.
bool _advancedMode = false;
FilterGroup _filterGroup = FilterGroup.empty();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -52,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
super.dispose(); super.dispose();
} }
void _toggleAdvanced() {
setState(() {
_advancedMode = !_advancedMode;
_results = null;
});
}
void _onChanged(String value) { void _onChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
if (value.trim().length < 3) { if (value.trim().length < 3) {
@@ -134,22 +148,47 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
} }
Future<void> _searchStructured() async {
if (_filterGroup.isEmpty) return;
setState(() => _loading = true);
try {
final emails = await ref
.read(emailRepositoryProvider)
.searchEmailsStructured(widget.accountId, _filterGroup);
if (mounted) {
setState(() {
_results = _SearchResults(
mailboxes: const [],
addresses: const [],
emails: emails,
);
_loading = false;
});
}
} catch (e) {
log('Structured search failed: $e');
if (mounted) setState(() => _loading = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: TextField( title: _advancedMode
controller: _ctrl, ? const Text('Advanced Search')
focusNode: _focusNode, : TextField(
autofocus: true, controller: _ctrl,
decoration: const InputDecoration( focusNode: _focusNode,
hintText: 'Search folders, addresses, emails…', autofocus: true,
border: InputBorder.none, decoration: const InputDecoration(
), hintText: 'Search folders, addresses, emails…',
onChanged: _onChanged, border: InputBorder.none,
), ),
onChanged: _onChanged,
),
actions: [ actions: [
if (_ctrl.text.isNotEmpty) if (!_advancedMode && _ctrl.text.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
@@ -157,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
setState(() => _results = null); setState(() => _results = null);
}, },
), ),
IconButton(
icon: Icon(
_advancedMode ? Icons.search : Icons.tune,
color:
_advancedMode ? Theme.of(context).colorScheme.primary : null,
),
tooltip: _advancedMode ? 'Simple search' : 'Advanced search',
onPressed: _toggleAdvanced,
),
], ],
), ),
body: _buildBody(), body: _buildBody(),
@@ -164,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
Widget _buildBody() { Widget _buildBody() {
if (_advancedMode) return _buildAdvancedBody();
if (_loading) return const Center(child: CircularProgressIndicator()); if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) { if (_results == null) {
if (_fieldFocused && _ctrl.text.isEmpty) { if (_fieldFocused && _ctrl.text.isEmpty) {
@@ -173,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
final r = _results!; final r = _results!;
if (r.isEmpty) return const Center(child: Text('No results')); if (r.isEmpty) return const Center(child: Text('No results'));
return _buildResultsList(r);
}
Widget _buildAdvancedBody() {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilterBuilderWidget(
initialValue: _filterGroup,
onChanged: (g) => setState(() {
_filterGroup = g;
_results = null;
}),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _filterGroup.isEmpty ? null : _searchStructured,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
if (_loading)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_results != null) ...[
const SizedBox(height: 8),
if (_results!.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No results'),
),
)
else
_buildResultsList(_results!),
],
],
),
);
}
Widget _buildResultsList(_SearchResults r) {
return ListView( return ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [ children: [
if (r.mailboxes.isNotEmpty) ...[ if (r.mailboxes.isNotEmpty) ...[
const _SectionHeader('Folders'), const _SectionHeader('Folders'),
@@ -188,9 +284,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[ if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'), const _SectionHeader('Messages'),
for (final e in r.emails) for (final e in r.emails)
EmailTile( ThreadTile(
email: e, thread: EmailThread.fromEmail(e),
showLocation: true, locationLabel: '${e.accountId}${e.mailboxPath}',
onTap: () => context.push( onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes' '/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}' '/${Uri.encodeComponent(e.mailboxPath)}'
+277 -13
View File
@@ -3,8 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
import 'package:sharedinbox/core/models/sieve_script.dart'; import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
class SieveScriptEditScreen extends ConsumerStatefulWidget { class SieveScriptEditScreen extends ConsumerStatefulWidget {
const SieveScriptEditScreen({ const SieveScriptEditScreen({
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
_SieveScriptEditScreenState(); _SieveScriptEditScreenState();
} }
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> { class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
with SingleTickerProviderStateMixin {
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _contentController; late final TextEditingController _contentController;
late final TabController _tabController;
bool _loadingContent = false; bool _loadingContent = false;
bool _saving = false; bool _saving = false;
String? _error; String? _error;
// Visual-editor state.
FilterGroup _filterGroup = FilterGroup.empty();
List<SieveAction> _actions = [];
bool _visualSupported = true;
int _visualLoadCount = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.script?.name ?? ''); _nameController = TextEditingController(text: widget.script?.name ?? '');
_contentController = TextEditingController(); _contentController = TextEditingController();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_onTabChanged);
if (widget.script != null) { if (widget.script != null) {
unawaited(_loadContent()); unawaited(_loadContent());
} }
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
void dispose() { void dispose() {
_nameController.dispose(); _nameController.dispose();
_contentController.dispose(); _contentController.dispose();
_tabController
..removeListener(_onTabChanged)
..dispose();
super.dispose(); super.dispose();
} }
void _onTabChanged() {
if (_tabController.indexIsChanging) return;
if (_tabController.index == 1) {
// Switched to Script tab: serialize visual state.
if (_visualSupported) {
_contentController.text =
SieveSerializer().serialize(_filterGroup, _actions);
}
} else {
// Switched to Visual tab: parse script into visual state.
_parseScriptIntoVisual();
}
}
void _parseScriptIntoVisual() {
final result = FilterSieveConverter().parse(_contentController.text);
if (result == null) {
setState(() => _visualSupported = false);
return;
}
setState(() {
_filterGroup = result.group;
_actions = List<SieveAction>.from(result.actions);
_visualSupported = true;
_visualLoadCount++;
});
}
Future<void> _loadContent() async { Future<void> _loadContent() async {
setState(() => _loadingContent = true); setState(() => _loadingContent = true);
try { try {
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
.getScriptContent(widget.accountId, widget.script!.blobId); .getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) { if (mounted) {
_contentController.text = content; _contentController.text = content;
_parseScriptIntoVisual();
setState(() => _loadingContent = false); setState(() => _loadingContent = false);
} }
} catch (e) { } catch (e) {
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
} }
Future<void> _save() async { Future<void> _save() async {
// Sync visual → script if on visual tab.
if (_tabController.index == 0 && _visualSupported) {
_contentController.text =
SieveSerializer().serialize(_filterGroup, _actions);
}
final name = _nameController.text.trim(); final name = _nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
setState(() => _error = 'Name is required'); setState(() => _error = 'Name is required');
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(isNew ? 'New script' : 'Edit script'), title: Text(isNew ? 'New script' : 'Edit script'),
bottom: TabBar(
controller: _tabController,
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
),
actions: [ actions: [
if (_saving) if (_saving)
const Padding( const Padding(
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
Expanded( Expanded(
child: TextField( child: TabBarView(
controller: _contentController, controller: _tabController,
decoration: const InputDecoration( children: [_buildVisualTab(), _buildScriptTab()],
labelText: 'Script',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace'),
enabled: !_saving,
), ),
), ),
], ],
@@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
), ),
); );
} }
Widget _buildVisualTab() {
if (!_visualSupported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'This script uses features not supported by the visual editor.\n'
'Edit as raw Sieve on the Script tab.',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilterBuilderWidget(
key: ValueKey(_visualLoadCount),
initialValue: _filterGroup,
onChanged: (g) => setState(() => _filterGroup = g),
),
const SizedBox(height: 12),
_ActionEditor(
actions: _actions,
onChanged: (a) => setState(() => _actions = a),
),
],
),
);
}
Widget _buildScriptTab() {
return TextField(
controller: _contentController,
decoration: const InputDecoration(
labelText: 'Script',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace'),
enabled: !_saving,
);
}
}
// ---------------------------------------------------------------------------
// Action editor
// ---------------------------------------------------------------------------
enum _ActionType { keep, discard, markAsRead, fileInto }
class _ActionEditor extends StatelessWidget {
const _ActionEditor({required this.actions, required this.onChanged});
final List<SieveAction> actions;
final void Function(List<SieveAction>) onChanged;
_ActionType _typeOf(SieveAction a) => switch (a) {
KeepAction() => _ActionType.keep,
DiscardAction() => _ActionType.discard,
MarkAsSeenAction() => _ActionType.markAsRead,
FileIntoAction() => _ActionType.fileInto,
FlagAction() => _ActionType.keep,
};
SieveAction _defaultFor(_ActionType t) => switch (t) {
_ActionType.keep => KeepAction(),
_ActionType.discard => DiscardAction(),
_ActionType.markAsRead => MarkAsSeenAction(),
_ActionType.fileInto => FileIntoAction(''),
};
void _changeType(int i, _ActionType t) {
final next = List<SieveAction>.from(actions);
final current = next[i];
if (t == _ActionType.fileInto && current is FileIntoAction) return;
next[i] = _defaultFor(t);
onChanged(next);
}
void _changeFolder(int i, String folder) {
final next = List<SieveAction>.from(actions);
next[i] = FileIntoAction(folder);
onChanged(next);
}
void _remove(int i) {
final next = List<SieveAction>.from(actions)..removeAt(i);
onChanged(next);
}
void _add() {
onChanged([...actions, KeepAction()]);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('Actions', style: Theme.of(context).textTheme.labelLarge),
),
for (var i = 0; i < actions.length; i++) _buildRow(context, i),
TextButton.icon(
onPressed: _add,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add action'),
),
],
);
}
Widget _buildRow(BuildContext context, int i) {
final action = actions[i];
final type = _typeOf(action);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
DropdownButton<_ActionType>(
value: type,
isDense: true,
underline: const SizedBox.shrink(),
onChanged: (t) {
if (t != null) _changeType(i, t);
},
items: const [
DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')),
DropdownMenuItem(
value: _ActionType.discard,
child: Text('Discard'),
),
DropdownMenuItem(
value: _ActionType.markAsRead,
child: Text('Mark as read'),
),
DropdownMenuItem(
value: _ActionType.fileInto,
child: Text('File into'),
),
],
),
if (type == _ActionType.fileInto) ...[
const SizedBox(width: 8),
Expanded(
child: _FolderField(
value: (action as FileIntoAction).folder,
onChanged: (v) => _changeFolder(i, v),
),
),
] else
const Spacer(),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
tooltip: 'Remove',
onPressed: () => _remove(i),
),
],
),
);
}
}
class _FolderField extends StatefulWidget {
const _FolderField({required this.value, required this.onChanged});
final String value;
final void Function(String) onChanged;
@override
State<_FolderField> createState() => _FolderFieldState();
}
class _FolderFieldState extends State<_FolderField> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(_FolderField old) {
super.didUpdateWidget(old);
if (widget.value != _ctrl.text) _ctrl.text = widget.value;
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
onChanged: widget.onChanged,
decoration: const InputDecoration(
hintText: 'folder',
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
);
}
} }
+1 -3
View File
@@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
), ),
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+139 -5
View File
@@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss'); final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} }
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
final buf = StringBuffer();
buf.writeln('## Sync Entry');
buf.writeln();
buf.writeln('| Property | Value |');
buf.writeln('|----------|-------|');
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
if (entry.protocol.isNotEmpty) {
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
}
final statusLabel = entry.isOk
? 'OK'
: entry.isPermanent
? 'Error (permanent)'
: 'Error';
buf.writeln('| Status | $statusLabel |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
if (entry.mailboxStats.isNotEmpty) {
buf.writeln();
buf.writeln('### Per mailbox');
buf.writeln();
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
buf.writeln('|---------|---------|------------|----------|');
for (final m in entry.mailboxStats) {
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
}
}
if (entry.errorMessage != null) {
buf.writeln();
buf.writeln('**Error:**');
buf.writeln();
buf.writeln(entry.errorMessage);
}
if (entry.stackTrace != null) {
buf.writeln();
buf.writeln('**Stack trace:**');
buf.writeln();
buf.writeln('```');
buf.write(entry.stackTrace);
buf.writeln('```');
}
return buf.toString();
}
class SyncLogScreen extends ConsumerStatefulWidget { class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId}); const SyncLogScreen({super.key, required this.accountId});
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId); ref.read(syncManagerProvider).syncNow(widget.accountId);
} }
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
PackageInfo? pkg;
try {
pkg = await PackageInfo.fromPlatform();
} catch (_) {}
final deviceModel = await getDeviceModel();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
);
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 3),
content: Text('Copied to clipboard'),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet')) ? const Center(child: Text('No sync entries yet'))
: ListView.builder( : ListView.builder(
itemCount: _entries.length, itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]), itemBuilder: (ctx, i) => _SyncLogTile(
entry: _entries[i],
onCopy: () => _copyEntry(_entries[i], ctx),
),
), ),
); );
} }
} }
class _SyncLogTile extends StatelessWidget { class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry}); const _SyncLogTile({required this.entry, required this.onCopy});
final SyncLogEntry entry; final SyncLogEntry entry;
final VoidCallback onCopy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final errorColor = theme.colorScheme.error; final errorColor = theme.colorScheme.error;
final subtitleText = entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: entry.isPermanent
? 'Error (permanent) · took $durationLabel'
: 'Error · took $durationLabel';
return ExpansionTile( return ExpansionTile(
leading: Icon( leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline, entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor), style: entry.isOk ? null : TextStyle(color: errorColor),
), ),
subtitle: Text( subtitle: Text(
entry.isOk subtitleText,
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor), style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
), ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy as markdown',
onPressed: onCopy,
),
const Icon(Icons.expand_more),
],
),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12), padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12), style: TextStyle(color: errorColor, fontSize: 12),
), ),
), ),
if (entry.stackTrace != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
child: Text(
'Stack trace',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.stackTrace!,
style: TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: Colors.red[300],
),
),
),
],
if (entry.protocolLog != null) ...[ if (entry.protocolLog != null) ...[
const Padding( const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2), padding: EdgeInsets.only(top: 6, bottom: 2),
+98 -46
View File
@@ -7,6 +7,8 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -28,9 +30,16 @@ class ThreadDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Thread')), appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -60,6 +69,20 @@ class ThreadDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
} }
class _EmailMessageCard extends ConsumerStatefulWidget { class _EmailMessageCard extends ConsumerStatefulWidget {
@@ -91,6 +114,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final trustedSenders =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
final senderEmail = widget.email.from.isNotEmpty
? widget.email.from.first.email.toLowerCase()
: null;
final isTrusted = senderEmail != null &&
trustedSenders.any((p) => globMatch(senderEmail, p));
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@@ -125,13 +156,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
], ],
), ),
), ),
if (_expanded) _buildExpandedBody(), if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
], ],
), ),
); );
} }
Widget _buildExpandedBody() { Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column( child: Column(
@@ -141,6 +172,17 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
FutureBuilder<EmailBody>( FutureBuilder<EmailBody>(
future: _bodyFuture, future: _bodyFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Failed to load email: ${snapshot.error}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
);
}
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center( return const Center(
child: Padding( child: Padding(
@@ -151,21 +193,51 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
} }
final body = snapshot.data!; final body = snapshot.data!;
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (hasHtml) ...[ if (hasHtml) ...[
if (!_loadRemoteImages) if (!effectiveLoadImages)
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.image_outlined, size: 16), icon: const Icon(Icons.image_outlined, size: 16),
label: const Text('Load remote images'), label: const Text('Load remote images'),
onPressed: () => onPressed: () {
setState(() => _loadRemoteImages = true), setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
),
),
);
}
},
), ),
SecureEmailWebView( SecureEmailWebView(
htmlBody: body.htmlBody!, htmlBody: body.htmlBody!,
loadRemoteImages: _loadRemoteImages, loadRemoteImages: effectiveLoadImages,
), ),
] else ] else
SelectableText( SelectableText(
@@ -229,47 +301,27 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
} }
Future<void> _delete() async { Future<void> _delete() async {
final confirmed = await showDialog<bool>( final repo = ref.read(emailRepositoryProvider);
context: context, // Fetch data first for IMAP undo support
builder: (ctx) => AlertDialog( final original = await repo.getEmail(widget.email.id);
title: const Text('Delete email'),
content: const Text('Move this email to Trash?'), final destPath = await repo.deleteEmail(widget.email.id);
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
if (!mounted) return; if (!mounted) return;
if (confirmed == true) { if (original != null) {
final repo = ref.read(emailRepositoryProvider); unawaited(
// Fetch data first for IMAP undo support ref.read(undoServiceProvider.notifier).pushAction(
final original = await repo.getEmail(widget.email.id); UndoAction(
id: DateTime.now().toIso8601String(),
final destPath = await repo.deleteEmail(widget.email.id); accountId: widget.email.accountId,
type: UndoType.delete,
if (!mounted) return; emailIds: [widget.email.id],
if (original != null) { sourceMailboxPath: widget.email.mailboxPath,
unawaited( destinationMailboxPath: destPath,
ref.read(undoServiceProvider.notifier).pushAction( originalEmails: [original],
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
),
), ),
); ),
} );
} }
} }
} }
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/di.dart';
class TrustedImageSendersScreen extends ConsumerWidget {
const TrustedImageSendersScreen({super.key, this.highlightedSender});
final String? highlightedSender;
@override
Widget build(BuildContext context, WidgetRef ref) {
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Allowed addresses for images')),
floatingActionButton: FloatingActionButton(
tooltip: 'Add address',
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
),
body: trustedSendersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading trusted senders')),
data: (senders) {
if (senders.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text(
'No addresses added yet. '
'Tap + to add an address or pattern (e.g. *@example.com), '
'or tap "Load remote images" in an email to add the sender automatically.',
),
);
}
return ListView.builder(
itemCount: senders.length,
itemBuilder: (context, index) {
final sender = senders[index];
final isHighlighted = sender == highlightedSender;
return ListTile(
title: Text(
sender,
style: isHighlighted
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
);
},
);
},
),
);
}
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setState) {
return AlertDialog(
title: const Text('Add allowed address'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address or pattern',
hintText: '*@example.com',
helperText: '* matches any characters, e.g. *@example.com',
),
onChanged: (_) => setState(() {}),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
_addSender(ref, value);
Navigator.of(ctx).pop();
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: controller.text.trim().isEmpty
? null
: () {
_addSender(ref, controller.text);
Navigator.of(ctx).pop();
},
child: const Text('Add'),
),
],
);
},
);
},
);
}
void _addSender(WidgetRef ref, String value) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(value.trim()),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
class UndoLogDetailScreen extends ConsumerWidget {
const UndoLogDetailScreen({super.key, required this.action});
final UndoAction action;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Undo Log Detail'),
actions: [
TextButton(
onPressed: () async {
await ref
.read(undoServiceProvider.notifier)
.undo(actionId: action.id);
if (context.mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Action undone.'),
),
);
}
},
child: const Text('Undo'),
),
],
),
body: ListView(
children: [
_SectionHeader(text: 'Transaction', theme: theme),
ListTile(
leading: const Icon(Icons.account_circle),
title: const Text('Account'),
subtitle: Text(action.accountId),
),
ListTile(
leading: Icon(
action.type == UndoType.delete
? Icons.delete_outline
: (action.type == UndoType.snooze
? Icons.access_time
: Icons.move_to_inbox),
color: action.type == UndoType.delete
? Colors.redAccent
: (action.type == UndoType.snooze
? Colors.orangeAccent
: Colors.blueAccent),
),
title: const Text('Action'),
subtitle: Text(action.type.name.toUpperCase()),
),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('Timestamp'),
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
),
_SectionHeader(text: 'Folders', theme: theme),
ListTile(
leading: const Icon(Icons.folder_open),
title: const Text('Source'),
subtitle: Text(action.sourceMailboxPath),
),
if (action.type == UndoType.move &&
action.destinationMailboxPath != null)
ListTile(
leading: const Icon(Icons.drive_file_move),
title: const Text('Destination'),
subtitle: Text(action.destinationMailboxPath!),
),
_SectionHeader(
text: 'Emails (${action.emailIds.length})',
theme: theme,
),
if (action.originalEmails.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'${action.emailIds.length} email(s) — details not available',
style: theme.textTheme.bodySmall,
),
),
...action.originalEmails.map((email) => _EmailTile(email: email)),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text, required this.theme});
final String text;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
);
}
}
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email});
final Email email;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(Unknown Sender)';
return ListTile(
leading: const Icon(Icons.email_outlined),
title: Text(email.subject ?? '(No Subject)'),
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
}
+6 -3
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
final extraCount = count > 1 ? ' (+${count - 1} more)' : ''; final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
return ListTile( return ListTile(
onTap: () => context.go(
'/accounts/undo-log/${action.id}',
extra: action,
),
leading: Icon( leading: Icon(
action.type == UndoType.delete action.type == UndoType.delete
? Icons.delete_outline ? Icons.delete_outline
@@ -84,9 +89,7 @@ class _UndoActionTile extends ConsumerWidget {
.read(undoServiceProvider.notifier) .read(undoServiceProvider.notifier)
.undo(actionId: action.id); .undo(actionId: action.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Action undone.'), content: Text('Action undone.'),
+241
View File
@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
const UserPreferencesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
final trustedCount = trustedSendersAsync.value?.length ?? 0;
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
body: prefsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading preferences')),
data: (prefs) => ListView(
children: [
ListTile(
title: Text(
'Menu bar position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the folder navigation menu is shown in the mailbox view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.menuPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMenuPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Open folder navigation from a button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Open folder navigation from the hamburger icon in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text('Show the back button in the top bar.'),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text('Show the next message in the mailbox.'),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text('Return to the message list.'),
value: AfterMailViewAction.showMailbox,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Offline email cache',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Pre-fetch email bodies in the background so they are available offline.',
),
),
RadioGroup<PrefetchMode>(
groupValue: prefs.prefetchMode,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updatePrefetchMode(value),
);
unawaited(registerBodyPrefetchTask(value));
},
child: const Column(
children: [
RadioListTile<PrefetchMode>(
title: Text('Wi-Fi only (default)'),
subtitle: Text(
'Pre-fetch bodies in the background when connected to Wi-Fi.',
),
value: PrefetchMode.wifiOnly,
),
RadioListTile<PrefetchMode>(
title: Text('Any network'),
subtitle: Text(
'Pre-fetch bodies on Wi-Fi and mobile data.',
),
value: PrefetchMode.always,
),
RadioListTile<PrefetchMode>(
title: Text('Disabled'),
subtitle: Text(
'Do not pre-fetch email bodies in the background.',
),
value: PrefetchMode.disabled,
),
],
),
),
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Text('Cache size limit:'),
const SizedBox(width: 16),
DropdownButton<int>(
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
items: const [
DropdownMenuItem(value: 50, child: Text('50 MB')),
DropdownMenuItem(value: 100, child: Text('100 MB')),
DropdownMenuItem(value: 200, child: Text('200 MB')),
DropdownMenuItem(value: 500, child: Text('500 MB')),
],
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateBodyCacheLimitMb(value),
);
},
),
],
),
),
const SizedBox(height: 8),
],
const Divider(),
ListTile(
title: Text(
'Allowed addresses for images',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: Text(
trustedCount == 0
? 'No addresses added yet.'
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/accounts/trusted-senders'),
),
],
),
),
);
}
int _nearestCacheOption(int mb) {
const options = [50, 100, 200, 500];
return options.reduce(
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
String buildAboutMarkdown({
required BuildContext context,
PackageInfo? pkg,
required int imapCount,
required int jmapCount,
String? deviceModel,
}) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString();
final textScale = MediaQuery.of(
context,
).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceModelLine =
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$deviceModelLine'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| Locale | $locale |\n'
'| Text Scale | $textScale× |\n'
'| DB Schema Version | $dbSchemaVersion |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
/// Fetches device model string, or null when unavailable.
Future<String?> getDeviceModel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.manufacturer} / ${android.model}';
} else if (Platform.isIOS) {
final ios = await info.iosInfo;
return ios.utsname.machine;
}
} catch (_) {}
return null;
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
+258
View File
@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
/// Full-screen dialog for browsing email headers, organised into groups.
class EmailHeadersDialog extends StatelessWidget {
const EmailHeadersDialog({super.key, required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: const Text('Mail Headers'),
leading: const CloseButton(),
),
body: _HeadersBody(headers: headers),
),
);
}
}
class _HeadersBody extends StatelessWidget {
const _HeadersBody({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final receivedHeaders = <EmailHeader>[];
final listHeaders = <EmailHeader>[];
final arcHeaders = <EmailHeader>[];
final otherHeaders = <EmailHeader>[];
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
final xByPrefix = <String, List<EmailHeader>>{};
for (final h in headers) {
final lower = h.name.toLowerCase();
if (lower == 'received') {
receivedHeaders.add(h);
continue;
}
if (lower.startsWith('list-')) {
listHeaders.add(h);
continue;
}
if (lower.startsWith('arc-')) {
arcHeaders.add(h);
continue;
}
if (lower.startsWith('x-')) {
final parts = h.name.split('-');
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
xByPrefix.putIfAbsent(prefix, () => []).add(h);
continue;
}
otherHeaders.add(h);
}
final sections = <Widget>[];
if (otherHeaders.isNotEmpty) {
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
}
if (listHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'List- Headers', headers: listHeaders),
);
}
if (receivedHeaders.isNotEmpty) {
sections.add(_ReceivedSection(headers: receivedHeaders));
}
if (arcHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
);
}
// X- headers at bottom, each prefix in its own collapsible group.
final sortedPrefixes = xByPrefix.keys.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
for (final prefix in sortedPrefixes) {
sections.add(
_HeadersSection(
title: '$prefix Headers',
headers: xByPrefix[prefix]!,
),
);
}
return ListView(children: sections);
}
}
class _HeadersSection extends StatelessWidget {
const _HeadersSection({required this.title, required this.headers});
final String title;
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return ExpansionTile(
title: Text('$title (${headers.length})'),
children: [
for (var i = 0; i < headers.length; i++)
_HeaderRow(header: headers[i], index: i),
],
);
}
}
/// Received headers section — collapsed by default; shows inter-hop delays.
class _ReceivedSection extends StatelessWidget {
const _ReceivedSection({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final entries = _buildEntries(headers);
return ExpansionTile(
title: Text('Received (${headers.length})'),
children: [
for (var i = 0; i < entries.length; i++) ...[
_HeaderRow(header: entries[i].header, index: i),
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
],
],
);
}
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
final timestamps =
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
return [
for (var i = 0; i < headers.length; i++)
_ReceivedEntry(
header: headers[i],
delay: _computeDelay(timestamps, i),
),
];
}
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
if (i >= timestamps.length - 1) return null;
final current = timestamps[i];
final next = timestamps[i + 1];
if (current == null || next == null) return null;
final d = current.difference(next);
return d.isNegative ? Duration.zero : d;
}
}
class _ReceivedEntry {
const _ReceivedEntry({required this.header, this.delay});
final EmailHeader header;
final Duration? delay;
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.header, required this.index});
final EmailHeader header;
final int index;
@override
Widget build(BuildContext context) {
final bg = index.isEven
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
}
}
class _DelayRow extends StatelessWidget {
const _DelayRow({required this.delay});
final Duration delay;
@override
Widget build(BuildContext context) {
final color = _delayColor(delay);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Icon(Icons.arrow_downward, size: 14, color: color),
const SizedBox(width: 4),
Text(
_formatDuration(delay),
style: TextStyle(
fontSize: 12,
color: color,
fontWeight:
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
),
),
],
),
);
}
}
/// Parses the RFC 2822 timestamp from a Received header value.
///
/// Received headers end with `; date`, e.g.:
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
DateTime? _parseReceivedTimestamp(String value) {
final semiIndex = value.lastIndexOf(';');
if (semiIndex < 0) return null;
var s = value.substring(semiIndex + 1).trim();
// Strip parenthesised comments like (UTC).
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
// Strip leading day-of-week abbreviation like "Mon, ".
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
// Collapse runs of whitespace.
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
for (final fmt in [
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
]) {
try {
return fmt.parse(s);
} catch (_) {}
}
return null;
}
String _formatDuration(Duration d) {
if (d.inSeconds < 60) return '${d.inSeconds}s';
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
}
Color _delayColor(Duration d) {
if (d.inSeconds < 30) return Colors.green;
if (d.inSeconds < 300) return Colors.orange;
return Colors.red;
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
/// A swipeable list tile for an [EmailThread].
///
/// Handles the [Dismissible] wrapper (archive left, delete right) and
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
/// line with the account name — used in the combined-inbox view.
class EmailThreadTile extends StatelessWidget {
const EmailThreadTile({
super.key,
required this.thread,
required this.isSelected,
required this.isSelecting,
required this.onTap,
required this.onLongPress,
required this.onDismissed,
this.showAccount = false,
this.accountName,
});
final EmailThread thread;
final bool isSelected;
final bool isSelecting;
final VoidCallback onTap;
final VoidCallback onLongPress;
final Future<void> Function(DismissDirection) onDismissed;
/// When true, renders an extra subtitle line with [accountName].
final bool showAccount;
final String? accountName;
@override
Widget build(BuildContext context) {
final t = thread;
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: isSelecting
? Checkbox(
value: isSelected,
onChanged: (_) => onTap(),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (showAccount && accountName != null)
Text(
accountName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
onTap: onTap,
onLongPress: onLongPress,
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
direction:
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: onDismissed,
child: tile,
);
}
static Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
/// A widget that lets the user build a structured [FilterGroup] interactively.
///
/// Use a [ValueKey] on this widget when replacing [initialValue] from the
/// outside (e.g., after loading a Sieve script) to force a full rebuild.
class FilterBuilderWidget extends StatefulWidget {
const FilterBuilderWidget({
super.key,
required this.initialValue,
required this.onChanged,
});
final FilterGroup initialValue;
final void Function(FilterGroup) onChanged;
@override
State<FilterBuilderWidget> createState() => _FilterBuilderWidgetState();
}
class _FilterBuilderWidgetState extends State<FilterBuilderWidget> {
late FilterGroup _group;
@override
void initState() {
super.initState();
_group = widget.initialValue;
}
void _update(FilterGroup g) {
setState(() => _group = g);
widget.onChanged(g);
}
@override
Widget build(BuildContext context) {
return _GroupEditor(
group: _group,
onChanged: _update,
depth: 0,
);
}
}
// ---------------------------------------------------------------------------
// Group editor
// ---------------------------------------------------------------------------
class _GroupEditor extends StatelessWidget {
const _GroupEditor({
super.key,
required this.group,
required this.onChanged,
required this.depth,
this.onRemoveGroup,
});
final FilterGroup group;
final void Function(FilterGroup) onChanged;
final int depth;
final VoidCallback? onRemoveGroup;
static const _maxDepth = 1;
void _setOperator(FilterOperator op) =>
onChanged(group.copyWith(operator: op));
void _addLeaf() {
final leaf = FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: '',
);
onChanged(group.copyWith(children: [...group.children, leaf]));
}
void _addSubGroup() {
final sub = FilterGroup(
operator: FilterOperator.and_,
children: [],
);
onChanged(group.copyWith(children: [...group.children, sub]));
}
void _replaceChild(int index, FilterNode node) {
final next = List<FilterNode>.from(group.children);
next[index] = node;
onChanged(group.copyWith(children: next));
}
void _removeChild(int index) {
final next = List<FilterNode>.from(group.children)..removeAt(index);
onChanged(group.copyWith(children: next));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRoot = depth == 0;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_OperatorRow(
operator: group.operator,
onChanged: _setOperator,
onRemove: onRemoveGroup,
),
for (var i = 0; i < group.children.length; i++) _buildChild(context, i),
const SizedBox(height: 6),
Row(
children: [
TextButton.icon(
onPressed: _addLeaf,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add condition'),
),
if (depth < _maxDepth)
TextButton.icon(
onPressed: _addSubGroup,
icon: const Icon(Icons.playlist_add, size: 16),
label: const Text('Add group'),
),
],
),
],
);
if (isRoot) return content;
return Card(
margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
color: theme.colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.all(8),
child: content,
),
);
}
Widget _buildChild(BuildContext context, int i) {
final child = group.children[i];
return switch (child) {
final FilterLeaf leaf => _LeafRow(
key: ValueKey(i),
leaf: leaf,
onChanged: (l) => _replaceChild(i, l),
onDelete: () => _removeChild(i),
),
final FilterGroup sub => _GroupEditor(
key: ValueKey(i),
group: sub,
onChanged: (g) => _replaceChild(i, g),
depth: depth + 1,
onRemoveGroup: () => _removeChild(i),
),
};
}
}
// ---------------------------------------------------------------------------
// Operator row (AND / OR toggle)
// ---------------------------------------------------------------------------
class _OperatorRow extends StatelessWidget {
const _OperatorRow({
required this.operator,
required this.onChanged,
this.onRemove,
});
final FilterOperator operator;
final void Function(FilterOperator) onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return Row(
children: [
SegmentedButton<FilterOperator>(
segments: const [
ButtonSegment(value: FilterOperator.and_, label: Text('AND')),
ButtonSegment(value: FilterOperator.or_, label: Text('OR')),
],
selected: {operator},
onSelectionChanged: (s) => onChanged(s.first),
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const Spacer(),
if (onRemove != null)
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: 'Remove group',
onPressed: onRemove,
),
],
);
}
}
// ---------------------------------------------------------------------------
// Leaf row (field | comparison | value | delete)
// ---------------------------------------------------------------------------
class _LeafRow extends StatefulWidget {
const _LeafRow({
super.key,
required this.leaf,
required this.onChanged,
required this.onDelete,
});
final FilterLeaf leaf;
final void Function(FilterLeaf) onChanged;
final VoidCallback onDelete;
@override
State<_LeafRow> createState() => _LeafRowState();
}
class _LeafRowState extends State<_LeafRow> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.leaf.value);
}
@override
void didUpdateWidget(_LeafRow old) {
super.didUpdateWidget(old);
if (widget.leaf.value != _ctrl.text) {
_ctrl.text = widget.leaf.value;
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onFieldChanged(FilterField? f) {
if (f == null) return;
final allowed = f.allowedComparisons;
final comp = allowed.contains(widget.leaf.comparison)
? widget.leaf.comparison
: allowed.first;
widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp));
}
void _onCompChanged(FilterComparison? c) {
if (c == null) return;
widget.onChanged(widget.leaf.copyWith(comparison: c));
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
DropdownButton<FilterField>(
value: widget.leaf.field,
onChanged: _onFieldChanged,
isDense: true,
underline: const SizedBox.shrink(),
items: FilterField.values
.map(
(f) => DropdownMenuItem(value: f, child: Text(f.label)),
)
.toList(),
),
const SizedBox(width: 8),
DropdownButton<FilterComparison>(
value: widget.leaf.comparison,
onChanged: _onCompChanged,
isDense: true,
underline: const SizedBox.shrink(),
items: widget.leaf.field.allowedComparisons
.map(
(c) => DropdownMenuItem(value: c, child: Text(c.label)),
)
.toList(),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _ctrl,
onChanged: (v) =>
widget.onChanged(widget.leaf.copyWith(value: v)),
decoration: const InputDecoration(
hintText: 'value',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
tooltip: 'Remove',
onPressed: widget.onDelete,
),
],
),
);
}
}
+22 -13
View File
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp"> <meta http-equiv="Content-Security-Policy" content="$csp">
<style> <style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; } body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
a { color: #1976D2; } a { color: #1976D2; }
* { box-sizing: border-box; } * { box-sizing: border-box; max-width: 100%; }
table { width: 100%; border-collapse: collapse; }
td, th { overflow-wrap: break-word; word-break: break-word; }
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
</style> </style>
</head> </head>
<body> <body>
@@ -108,12 +111,16 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
Future<void> _measureHeight(String _) async { Future<void> _measureHeight(String _) async {
final result = await _controller!.runJavaScriptReturningResult( try {
'document.documentElement.scrollHeight', final result = await _controller!.runJavaScriptReturningResult(
); 'document.documentElement.scrollHeight',
final h = double.tryParse(result.toString()); );
if (h != null && h > 0 && mounted) { final h = double.tryParse(result.toString());
setState(() => _height = h); if (h != null && h > 0 && mounted) {
setState(() => _height = h);
}
} catch (_) {
// WebView not ready yet; height stays at default
} }
} }
@@ -184,12 +191,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final launched = final launched = await launchUrl(
await launchUrl(uri, mode: LaunchMode.externalApplication); uri,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) { if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Could not open: $url')), context,
); ).showSnackBar(SnackBar(content: Text('Could not open: $url')));
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More