Compare commits

..
Author SHA1 Message Date
Thomas GuettlerandClaude Sonnet 4.6 6853ad130f fix(test): sync before searching in second searchEmails IMAP test
The searchEmails implementation now queries local SQLite FTS5 (not IMAP),
so syncEmails must be called first to populate the index. The first test
was already fixed; this adds the same syncEmails call to the second test
and adds a clarifying comment to the implementation.

Closes #506

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:49:05 +00: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
103 changed files with 5148 additions and 1312 deletions
+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
+33 -1
View File
@@ -1,10 +1,42 @@
name: CI
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Full Project Check
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env:
+120
View File
@@ -15,6 +15,30 @@ jobs:
linux: ${{ steps.diff.outputs.linux }}
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -141,6 +165,30 @@ jobs:
if: needs.check-changes.outputs.android == 'true'
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 100
@@ -175,6 +223,30 @@ jobs:
if: needs.check-changes.outputs.android == 'true'
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 100
@@ -203,6 +275,30 @@ jobs:
if: needs.check-changes.outputs.linux == 'true'
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 100
@@ -236,6 +332,30 @@ jobs:
timeout-minutes: 5
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
env:
FORGEJO_TOKEN: ${{ github.token }}
+48
View File
@@ -14,6 +14,30 @@ jobs:
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_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -50,6 +74,30 @@ jobs:
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_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 1
+24
View File
@@ -18,6 +18,30 @@ jobs:
timeout-minutes: 60
steps:
- name: Print runner wait time
env:
FORGEJO_TOKEN: ${{ github.token }}
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
- uses: actions/checkout@v4
with:
submodules: recursive
+1
View File
@@ -10,6 +10,7 @@ jobs:
# Disabled until a self-hosted runner with label "windows-runner" is registered.
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
timeout-minutes: 90
if: false
steps:
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.1"
"flutter": "3.44.0"
}
-250
View File
@@ -1,250 +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
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- 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 "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
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)
if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$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' \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
+1
View File
@@ -1,5 +1,6 @@
# --- Flutter/Dart ---
coverage/
screenshots/
.dart_tool/
.dart-tool/
.packages
+12 -1
View File
@@ -10,6 +10,11 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/guettli/sync-branch
rev: v0.0.11
hooks:
- id: sync-branch
- repo: local
hooks:
- id: check-no-binary
@@ -27,7 +32,7 @@ repos:
- id: dart-check
name: dart format (autofix) + check-fast (parallel)
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
always_run: true
- id: ci-no-direct-dagger
@@ -42,3 +47,9 @@ repos:
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
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)$
-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.
-5
View File
@@ -216,8 +216,3 @@ test/
- **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
+74 -51
View File
@@ -37,6 +37,8 @@ tasks:
run: once
deps: [_nix-check]
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}"
msg: "Not in nix dev shell. Run: nix develop"
cmds:
@@ -56,6 +58,14 @@ tasks:
cmds:
- 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:
desc: Generate assets/changelog.txt from git history
cmds:
@@ -96,34 +106,19 @@ tasks:
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
codegen:
desc: Generate Drift DB code (run after any schema change)
deps: [_preflight, _pub-get]
sources:
- lib/**/*.dart
- pubspec.yaml
generates:
- lib/**/*.g.dart
desc: Generate Drift DB code via Dagger (exports generated files back to host)
cmds:
- fvm flutter pub run build_runner build --delete-conflicting-outputs
- dagger call --progress=plain -q -m ci --source=. codegen -o .
analyze:
desc: Static analysis (flutter analyze)
deps: [_preflight, _codegen]
sources:
- lib/**/*.dart
- test/**/*.dart
- pubspec.yaml
- analysis_options.yaml
desc: Static analysis via Dagger (dart analyze --fatal-infos)
cmds:
- scripts/run_analyze.sh
- dagger call --progress=plain -q -m ci --source=. analyze
format:
desc: Format all Dart source files
deps: [_preflight]
sources:
- "**/*.dart"
desc: Format all Dart source files via Dagger (writes back to host)
cmds:
- fvm dart format lib test
- dagger call --progress=plain -q -m ci --source=. format-write -o .
check-mocks:
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
analyze-fix:
desc: Auto-fix lint issues with dart fix --apply
deps: [_preflight]
sources:
- lib/**/*.dart
- test/**/*.dart
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
cmds:
- fvm dart fix --apply
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
test:
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
@@ -177,17 +168,17 @@ tasks:
test-backend:
desc: Backend tests against a local Stalwart mail server (via Dagger)
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:
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
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:
desc: Run sync reliability runner (via Dagger)
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:
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
@@ -202,7 +193,7 @@ tasks:
ci-graph:
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
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:
desc: Start a Stalwart instance for local development (via Dagger)
@@ -218,13 +209,13 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && 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"
- 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:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds:
- mkdir -p build/app/outputs/bundle/release
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -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:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -234,7 +225,7 @@ tasks:
- sh: test -f build/app/outputs/bundle/release/app-release.aab
msg: "AAB not found — run build-android-bundle first"
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:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
@@ -247,7 +238,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && 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"
- 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:
desc: Build and deploy Android APK via Dagger
@@ -261,7 +252,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && 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)"
- 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:
desc: Build and publish website via Dagger
@@ -271,7 +262,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && 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"
- 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:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -351,7 +342,7 @@ tasks:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
@@ -426,6 +417,22 @@ tasks:
fi
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:
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
deps: [_pub-get, generate-changelog]
@@ -522,18 +529,10 @@ tasks:
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"
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:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
dotenv: [".env"]
method: timestamp
sources:
- lib/**/*.dart
@@ -542,7 +541,14 @@ tasks:
generates:
- build/app/outputs/bundle/release/app-release.aab
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:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
@@ -569,7 +575,7 @@ tasks:
run:
desc: Run the app on Linux desktop
deps: [_preflight, _linux-deps-check, _pub-get]
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
cmds:
- fvm flutter run -d linux --no-pub
@@ -672,8 +678,9 @@ tasks:
${SSH_USER}@${SSH_HOST}:public_html/
check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
cmds:
- dagger call --progress=plain -q -m ci --source=. check-fast
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
@@ -700,6 +707,11 @@ tasks:
fi
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:
internal: true
run: once
@@ -712,6 +724,17 @@ tasks:
cmds:
- 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:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test]
+13 -16
View File
@@ -22,15 +22,17 @@ android {
}
}
signingConfigs {
create("release") {
// Hardcoded alias matching t.sh
keyAlias = "upload"
// Use the same password for both key and keystore
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
storePassword = pass
keyPassword = pass
storeFile = file("upload-keystore.jks")
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
if (ksPath != null) {
signingConfigs {
create("release") {
keyAlias = "upload"
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
storePassword = pass
keyPassword = pass
storeFile = file(ksPath)
}
}
}
@@ -46,14 +48,9 @@ android {
buildTypes {
release {
// Use the signing config defined above for release builds.
// If the keystore file exists (e.g. in CI or manually placed), sign it.
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
if (ksPath != null) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = false
isShrinkResources = false
ndk {
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
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
+1 -41
View File
@@ -2,44 +2,4 @@ module dagger/ci
go 1.26.2
require (
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.44.0
go.opentelemetry.io/otel/trace v1.44.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.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/log v0.20.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0
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-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
require golang.org/x/sync v0.20.0
-127
View File
@@ -1,129 +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 v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
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/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
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/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
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/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
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/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
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 v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
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/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
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/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
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/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
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/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
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 v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
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 v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
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/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
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/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
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/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/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
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/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/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/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
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=
+118 -22
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"dagger/ci/internal/dagger"
"encoding/json"
"fmt"
"time"
@@ -148,16 +149,33 @@ if __name__ == "__main__":
`
type Ci struct {
Source *dagger.Directory
Source *dagger.Directory
FlutterVersion string
}
func New(
ctx context.Context,
// +defaultPath=".."
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{
FlutterVersion: fvmrc.Flutter,
Source: source.Filter(dagger.DirectoryFilterOpts{
Include: []string{
".fvmrc",
"lib/",
"test/",
"assets/",
@@ -173,7 +191,7 @@ func New(
"website/",
},
}),
}
}, nil
}
// 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.
func (m *Ci) toolchain() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.44.0").
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
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{"useradd", "-m", "-s", "/bin/bash", "ci"}).
@@ -338,12 +356,17 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
// Mount at a raw path so we can normalise before use: strip any CRLF line
// endings that appear when the key is stored or exported on Windows, which
// cause "error in libcrypto" in Alpine's LibreSSL-backed openssh.
WithMountedSecret("/root/.ssh/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithExec([]string{"sh", "-c",
"tr -d '\\r' < /root/.ssh/id_ed25519.raw > /root/.ssh/id_ed25519 && chmod 600 /root/.ssh/id_ed25519"}).
// Create .ssh with strict permissions before Dagger mounts anything there,
// 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")
}
@@ -417,11 +440,73 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
Stdout(ctx)
}
// CheckMocks verifies that generated mocks are up to date.
// It snapshots the committed source (including any stale *.mocks.dart) before
// FormatWrite formats Dart files and exports the modified /src directory.
func (m *Ci) FormatWrite() *dagger.Directory {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "format", "lib", "test"}).
Directory("/src")
}
// Analyze runs static analysis with dart analyze --fatal-infos.
func (m *Ci) Analyze(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
Stdout(ctx)
}
// 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 snapshots the committed source (including any stale generated files) before
// running build_runner, so git diff detects real staleness instead of always
// comparing two freshly-generated outputs.
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
return m.pubGetLayer().
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
@@ -434,16 +519,16 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
`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.\""}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
Stdout(ctx)
}
// Coverage runs unit tests with coverage gate.
// Coverage runs unit and widget tests with coverage gate.
func (m *Ci) Coverage(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"/bin/bash", "-c",
`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"`}).
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
Stdout(ctx)
@@ -480,6 +565,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
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 >"$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.
func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
@@ -510,7 +605,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return analyze, err
}
mocks, err := m.CheckMocks(ctx)
mocks, err := m.CheckGenerated(ctx)
if err != nil {
return mocks, err
}
@@ -664,7 +759,8 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
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.
@@ -897,12 +993,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
//
// dagger call --progress=plain -q -m ci --source=. graph
func (m *Ci) Graph() string {
return `# CI Pipeline Graph
return fmt.Sprintf(`# CI Pipeline Graph
` + "```" + `mermaid
`+"```"+`mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
@@ -912,7 +1008,7 @@ flowchart TD
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
codegen --> fmt["Format"]
codegen --> analyze["Analyze"]
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load .env into environment
+23 -1
View File
@@ -48,11 +48,28 @@
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 {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Dagger CLI
dagger.packages.${system}.dagger
dagger021
# Go compiler — for Dagger development
go
@@ -99,12 +116,17 @@
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
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 = ''
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
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
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

+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 37;
const int dbSchemaVersion = 40;
+16
View File
@@ -192,6 +192,22 @@ class EmailThread {
required this.accountId,
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 {
+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,
});
}
+17
View File
@@ -2,13 +2,30 @@ 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;
}
+1 -1
View File
@@ -58,7 +58,7 @@ abstract class EmailRepository {
);
/// 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);
/// Returns all locally cached emails in any mailbox of [accountId] (or all
@@ -20,4 +20,8 @@ abstract class MailboxRepository {
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);
}
@@ -5,6 +5,8 @@ abstract class UserPreferencesRepository {
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);
+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();
}
}
+50 -2
View File
@@ -11,7 +11,9 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
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/services/body_cache_service.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
@@ -21,6 +23,7 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
import 'package:workmanager/workmanager.dart';
const _kTaskName = 'si_bg_sync';
const _kPrefetchTaskName = 'si_bg_prefetch';
const _kResourceType = 'background_check';
@pragma('vm:entry-point')
@@ -28,9 +31,13 @@ void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((_, __) async {
Workmanager().executeTask((taskName, __) async {
try {
await _doBackgroundSync();
if (taskName == _kPrefetchTaskName) {
await _doBodyPrefetch();
} else {
await _doBackgroundSync();
}
} catch (_) {}
return true;
});
@@ -55,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 {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
@@ -76,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(
AppDatabase db,
AccountRepository accountRepo,
+98 -10
View File
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
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';
@@ -318,6 +319,37 @@ class ImageTrustedSenders extends Table {
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 {
@@ -330,6 +362,12 @@ class UserPreferences extends Table {
// 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};
@@ -357,6 +395,8 @@ class UserPreferences extends Table {
ShareKeys,
UserPreferences,
ImageTrustedSenders,
EmailNotes,
InstalledVersions,
],
)
class AppDatabase extends _$AppDatabase {
@@ -626,8 +666,40 @@ class AppDatabase extends _$AppDatabase {
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);
}
},
);
/// 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().
@@ -722,18 +794,34 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
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() {
return LazyDatabase(() async {
final file = File(await _resolveDatabasePath());
return NativeDatabase.createInBackground(
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;');
},
);
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
});
}
// 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);
@@ -2934,6 +2934,60 @@ class EmailRepositoryImpl implements EmailRepository {
final emailRows = await Future.wait(
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);
}
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();
}
@@ -3047,68 +3101,42 @@ class EmailRepositoryImpl implements EmailRepository {
}
@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(
String accountId,
String mailboxPath,
String query,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
final ftsQuery = _toFtsQuery(query);
if (ftsQuery.isEmpty) return [];
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
' ORDER BY rank 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(
imap.MessageSequence.fromIds(uids, isUid: true),
'(UID FLAGS ENVELOPE)',
);
return fetch.messages
.where((msg) => msg.uid != null && msg.envelope != null)
.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();
final noteRows =
await _searchEmailsByNotes(accountId, mailboxPath, query);
final seen = <String>{};
final merged = <model.Email>[];
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
if (seen.add(e.id)) merged.add(e);
}
return merged;
}
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
(addresses ?? const [])
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
.toList();
// ── Helpers ────────────────────────────────────────────────────────────────
/// Computes a stable threadId from RFC 2822 headers.
@@ -343,11 +343,23 @@ class MailboxRepositoryImpl implements MailboxRepository {
}
}
@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,
String? role,
) async {
final client = await _imapConnect(
account,
@@ -380,7 +392,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
account_model.Account account,
String password,
String name,
String role,
String? role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
@@ -398,7 +410,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
'new-mailbox': {
'name': name,
if (role != null) 'role': role,
},
},
},
'0',
@@ -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)}';
}
}
@@ -50,6 +50,26 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
);
}
@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)
@@ -90,6 +110,8 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
bodyCacheLimitMb: row.bodyCacheLimitMb,
);
}
}
+22
View File
@@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
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/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_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/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
@@ -32,6 +34,7 @@ import 'package:sharedinbox/data/repositories/account_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/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/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
@@ -282,3 +285,22 @@ final trustedImageSendersProvider =
.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),
);
+39 -5
View File
@@ -5,19 +5,30 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/sync/background_sync.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/router.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(
runZonedGuarded(
() async {
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.
ErrorWidget.builder = (details) => CrashScreen(
exception: details.exception,
@@ -39,19 +50,35 @@ void main({List<Override> overrides = const []}) async {
if (Platform.isAndroid) {
await initNotifications();
await registerBackgroundSync();
await _registerPrefetchTaskFromStoredPrefs();
}
runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
);
},
(error, stack) {
// Catch unhandled async errors.
runApp(CrashScreen(exception: error, stackTrace: stack));
},
// This handler runs in the parent zone — runApp cannot be called here.
// Framework errors are already handled by FlutterError.onError above.
(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 {
const SharedInboxApp({super.key});
@@ -59,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
}
const _kGitHash = String.fromEnvironment('GIT_HASH');
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@override
void initState() {
@@ -66,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
// Start background IMAP sync once — runs for the lifetime of the app.
ref.read(syncManagerProvider).start();
ref.read(reliabilityRunnerProvider).start();
if (_kGitHash.isNotEmpty) {
unawaited(
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
);
}
}
@override
+24
View File
@@ -1,6 +1,7 @@
import 'package:go_router/go_router.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/account_list_screen.dart';
@@ -8,6 +9,7 @@ import 'package:sharedinbox/ui/screens/account_receive_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/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/combined_inbox_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
@@ -20,6 +22,8 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_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/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/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
@@ -53,6 +57,14 @@ final router = GoRouter(
GoRoute(
path: 'undo-log',
builder: (ctx, state) => const UndoLogScreen(),
routes: [
GoRoute(
path: ':actionId',
builder: (ctx, state) => UndoLogDetailScreen(
action: state.extra as UndoAction,
),
),
],
),
GoRoute(
path: 'changelog',
@@ -66,6 +78,12 @@ final router = GoRouter(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: 'trusted-senders',
builder: (ctx, state) => TrustedImageSendersScreen(
highlightedSender: state.extra as String?,
),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
@@ -169,6 +187,12 @@ final router = GoRouter(
);
},
),
GoRoute(
path: '/bug-report',
builder: (ctx, state) => BugReportScreen(
emailId: state.uri.queryParameters['emailId'],
),
),
],
),
],
+14 -5
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.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:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
@@ -197,22 +198,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy to clipboard'),
label: const Text('Copy info'),
onPressed: () => unawaited(
_copyToClipboard(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 8),
const SizedBox(width: 4),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.bug_report),
label: const Text('Create issue'),
child: OutlinedButton.icon(
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Public issue'),
onPressed: () => unawaited(
_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'),
),
),
],
),
),
+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 -8
View File
@@ -2,21 +2,90 @@ import 'dart:async';
import 'package:flutter/material.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';
class ChangeLogScreen extends StatelessWidget {
class ChangeLogScreen extends ConsumerWidget {
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
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final installedVersions = ref.watch(installedVersionsProvider);
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future: DefaultAssetBundle.of(
context,
).loadString('assets/changelog.txt'),
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.connectionState == ConnectionState.waiting ||
installedVersions.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
@@ -24,9 +93,12 @@ class ChangeLogScreen extends StatelessWidget {
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(
data: content,
data: annotated,
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
+171 -142
View File
@@ -3,20 +3,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.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';
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);
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@@ -30,6 +22,31 @@ 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);
@@ -58,18 +75,38 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _buildDrawer(context, accounts),
drawer: _selecting ? null : _buildDrawer(context, accounts),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: _buildBody(accountNames, showAccount),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
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: [
@@ -91,6 +128,26 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
);
}
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(
@@ -176,6 +233,7 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
_currentThreads = threads;
if (threads.isEmpty) {
return ListView(
children: const [
@@ -207,119 +265,33 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
child: const Text('Load more'),
);
}
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
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),
);
},
);
}
Widget _buildThreadTile(
BuildContext ctx,
EmailThread t,
Map<String, String> accountNames,
bool showAccount,
) {
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: 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,
),
if (showAccount)
Text(
accountNames[t.accountId] ?? t.accountId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.primary,
),
),
],
),
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: 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)}',
),
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
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) => unawaited(_onSwipeDismissed(t, direction)),
child: tile,
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
@@ -370,24 +342,81 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
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)),
],
),
);
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));
}
}
}
+199 -16
View File
@@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.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/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
@@ -37,6 +38,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
bool _notesSynced = false;
@override
Widget build(BuildContext context) {
@@ -50,6 +52,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
if (!_notesSynced && email?.messageId != null) {
_notesSynced = true;
unawaited(
ref.read(noteRepositoryProvider).syncNotes(
email!.accountId,
email.messageId!,
),
);
}
},
);
@@ -93,19 +104,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
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],
),
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);
@@ -143,6 +152,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Mail Structure'),
),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'bug_report',
child: Text('Report a Bug'),
),
],
onSelected: (value) async {
if (value == 'forward' && header != null) {
@@ -163,6 +177,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_showStructure(context, body);
} else if (value == 'rfc') {
unawaited(_showRaw(context, header));
} else if (value == 'bug_report') {
unawaited(
context.push('/bug-report?emailId=${widget.emailId}'),
);
}
},
),
@@ -222,11 +240,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
@@ -247,6 +268,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body.textBody ?? '',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (header?.messageId != null) _buildNotesSection(ctx, header!),
if (body.attachments.isNotEmpty) ...[
const Divider(),
Padding(
@@ -330,6 +352,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) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -553,6 +683,42 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
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 {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
@@ -566,6 +732,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return;
const createNewSentinel = '__create_new__';
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
@@ -583,13 +751,28 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
title: Text(m.name),
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;
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(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -599,7 +782,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: chosen,
destinationMailboxPath: destination,
),
),
);
+103 -190
View File
@@ -12,19 +12,10 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.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);
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
@@ -59,6 +50,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
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 =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -70,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() {
_searchResults = null;
_searchLoading = false;
_lastSettledQuery = null;
});
}
});
@@ -126,18 +127,35 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
Future<void> _runSearch(String query) async {
if (query.trim().isEmpty) {
setState(() => _searchResults = null);
final q = query.trim();
if (q.isEmpty) {
setState(() {
_searchResults = null;
_lastSettledQuery = null;
});
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);
try {
final results = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
if (mounted) setState(() => _searchResults = results);
.searchEmails(widget.accountId, widget.mailboxPath, q);
if (mounted && generation == _searchGeneration) {
setState(() {
_searchResults = results;
_lastSettledQuery = q;
});
}
} finally {
if (mounted) setState(() => _searchLoading = false);
if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
}
}
@@ -550,8 +568,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
if (wasSearching && mounted) {
// Filter deleted emails out of the local results immediately.
// Calling searchEmails here would hit the IMAP server, which still has
// the emails because the delete is only enqueued — not yet applied.
// Calling searchEmails here would still return deleted rows because the
// delete is only enqueued — not yet applied to the local DB.
final deletedIds = ids.toSet();
final remaining = (_searchResults ?? [])
.where((e) => !deletedIds.contains(e.id))
@@ -688,177 +706,93 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
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,
),
],
),
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => 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(
'/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),
);
// 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,
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
},
);
}
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.
Widget _buildEmailList(List<Email> emails) {
return ListView.builder(
itemCount: emails.length,
itemBuilder: (ctx, i) {
final e = emails[i];
final t = EmailThread.fromEmail(e);
final isSelected = _selectedSearchIds.contains(e.id);
return EmailTile(
email: e,
return ThreadTile(
thread: t,
selected: isSelected,
leading: SizedBox(
width: 40,
@@ -877,25 +811,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)),
],
),
);
}
}
+6 -4
View File
@@ -51,10 +51,12 @@ class MailboxListScreen extends ConsumerWidget {
? BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
),
],
),
+4 -4
View File
@@ -8,7 +8,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref,
@@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'),
for (final e in r.emails)
EmailTile(
email: e,
showLocation: true,
ThreadTile(
thread: EmailThread.fromEmail(e),
locationLabel: '${e.accountId}${e.mailboxPath}',
onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}'
+24 -41
View File
@@ -217,11 +217,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
@@ -297,47 +300,27 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
}
Future<void> _delete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete email'),
content: const Text('Move this email to Trash?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
final repo = ref.read(emailRepositoryProvider);
// Fetch data first for IMAP undo support
final original = await repo.getEmail(widget.email.id);
final destPath = await repo.deleteEmail(widget.email.id);
if (!mounted) return;
if (confirmed == true) {
final repo = ref.read(emailRepositoryProvider);
// Fetch data first for IMAP undo support
final original = await repo.getEmail(widget.email.id);
final destPath = await repo.deleteEmail(widget.email.id);
if (!mounted) return;
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
),
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
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,63 @@
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')),
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 "Load remote images" in an email to add the sender.',
),
);
}
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),
);
},
),
);
},
);
},
),
);
}
}
+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),
);
}
}
+5
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
return ListTile(
onTap: () => context.go(
'/accounts/undo-log/${action.id}',
extra: action,
),
leading: Icon(
action.type == UndoType.delete
? Icons.delete_outline
+92 -30
View File
@@ -2,8 +2,10 @@ 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 {
@@ -13,6 +15,7 @@ class UserPreferencesScreen extends ConsumerWidget {
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')),
@@ -135,45 +138,104 @@ class UserPreferencesScreen extends ConsumerWidget {
const Divider(),
ListTile(
title: Text(
'Trusted image senders',
'Offline email cache',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Remote images are loaded automatically for these senders.',
'Pre-fetch email bodies in the background so they are available offline.',
),
),
...trustedSendersAsync.when(
loading: () => const [],
error: (_, __) => const [],
data: (senders) => senders.isEmpty
? [
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No trusted senders yet.'),
),
]
: [
for (final sender in senders)
ListTile(
title: Text(sender),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
),
],
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,
);
}
}
+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)),
],
),
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
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 list tile for an [EmailThread].
///
/// Used in inbox lists, combined inbox, and search result lists.
/// Pass a custom [leading] widget to support selection-mode checkboxes.
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
/// "accountId • mailboxPath") — useful in cross-mailbox views.
class ThreadTile extends StatelessWidget {
const ThreadTile({
super.key,
required this.thread,
required this.onTap,
this.leading,
this.selected = false,
this.onLongPress,
this.locationLabel,
});
final EmailThread thread;
final VoidCallback onTap;
final Widget? leading;
final bool selected;
final VoidCallback? onLongPress;
/// When non-null, appended as an extra subtitle line in primary colour.
final String? locationLabel;
@override
Widget build(BuildContext context) {
final senderNames = thread.participants.isEmpty
? '(unknown)'
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
return ListTile(
leading: leading ??
Icon(
thread.hasUnread ? Icons.mail : Icons.mail_outline,
color:
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (thread.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${thread.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
thread.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (thread.preview != null && thread.preview!.isNotEmpty)
Text(
thread.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (locationLabel != null)
Text(
locationLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (thread.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(thread.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
selected: selected,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
+4
View File
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
+2
View File
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
// Show AFTER adding FlView so GTK's first layout pass allocates the full
// window content area (1280×800) to FlView, not the default 1×1.
gtk_widget_show_all(GTK_WIDGET(window));
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

-66
View File
@@ -1,66 +0,0 @@
# Next
## Introduction
Continue the momentum from the safety hardening and infrastructure work.
The focus is on making the app ready for real-world use with robust error
handling and performance optimizations.
Create several small commits. Every commit should be self contained.
while working create/append to plan.log, so that the user sees what you are working on.
## Tasks
### 0. deploy-android
Make `task deploy-android` work.
### 0.5 Debug duration of deploy-android
Is there a way to make deploy-android faster?
Use `task --verbose` to see what gets done.
Maybe avoid doing things again, when nothing changed.
Taskfile has features to avoid calling things again, when the input has not changed.
### 1. Fix Android E2E Race Condition (aliceTile)
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
The current "double pumpUntil" fix isn't reliable enough.
Investigate if the animation state or the Drift stream propagation is the
culprit.
### 2. Implement Global Crash Screen
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
Implement a `CrashScreen` widget that shows the stack trace and a
"Copy to Clipboard" button for user reporting.
### 3. Database-Backed Threading
Currently, emails are grouped into threads in-memory in the repository.
Refactor to store thread relationships in the local SQLite database.
This is necessary for performance on mailboxes with thousands of messages.
### 4. Implement Undo for Bulk Actions
Add a global "Undo" snackbar after deleting or moving emails.
The system needs to handle the three sync states:
- Queued (easy to undo)
- In-progress (cancel network call)
- Finished (requires a reverse move/un-delete)
### 5. Transition to Real Account Testing
Prepare the integration tests to run against a real test account
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
This verifies the app against real-world network latency and RFC edge cases.
### 6. Coverage Gate Maintenance
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
Add a test to ensure the exclusion list doesn't contain files that no longer
exist ("ghost paths").
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+17 -1
View File
@@ -371,6 +371,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -562,6 +570,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
integration_test:
dependency: "direct dev"
description: flutter
@@ -1021,7 +1037,7 @@ packages:
source: hosted
version: "0.44.4"
stack_trace:
dependency: transitive
dependency: "direct main"
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+13 -1
View File
@@ -19,6 +19,7 @@ dependencies:
# Local persistence (offline-first)
drift: ^2.20.3
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
sqlite3_flutter_libs: ^0.6.0+eol
path_provider: ^2.1.5
path: ^1.9.1
@@ -58,6 +59,9 @@ dependencies:
flutter_local_notifications: ^21.0.0
workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
stack_trace: ^1.12.1
# App version metadata for crash reports
package_info_plus: ^10.1.0
share_plus: ^13.1.0
@@ -75,9 +79,17 @@ dev_dependencies:
mockito: ^5.4.4
fake_async: ^1.3.1
path_provider_platform_interface: ^2.1.2
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
url_launcher_platform_interface: ^2.3.2
plugin_platform_interface: ^2.1.8
flutter_launcher_icons: ^0.14.0
flutter_icons:
android: "ic_launcher"
ios: false
image_path: "icon.png"
linux:
generate: true
image_path: "icon.png"
flutter:
uses-material-design: true
+8
View File
@@ -19,6 +19,14 @@
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^\\.fvmrc$"],
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
"datasourceTemplate": "docker",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"fileMatch": ["^\\.forgejo/Dockerfile$"],
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
trap "rm -f $tmp" EXIT
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
ANDROID_KEYSTORE_PATH="$tmp" \
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"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Verify that every container image referenced in ci/main.go is reachable.
# Runs skopeo inspect (manifest-only, no layer pull) for each From("...") call.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
FILE="$ROOT/ci/main.go"
# Static images from From("...") literals in ci/main.go
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
FVMRC="$ROOT/.fvmrc"
flutter_version=$(python3 -c "import json; print(json.load(open('$FVMRC'))['flutter'])" 2>/dev/null || true)
flutter_image=""
if [ -n "$flutter_version" ]; then
flutter_image="ghcr.io/cirruslabs/flutter:$flutter_version"
fi
images=$(printf '%s\n%s\n' "$static_images" "$flutter_image" | grep -v '^$' | sort -u)
if [ -z "$images" ]; then
echo "check-ci-images: no From() image references found in $FILE"
exit 0
fi
fail=0
while IFS= read -r image; do
printf "check-ci-images: %-55s" "$image"
if skopeo inspect --no-creds "docker://$image" > /dev/null 2>&1; then
echo "OK"
else
echo "NOT FOUND"
fail=1
fi
done <<< "$images"
if [ "$fail" -eq 1 ]; then
echo ""
echo "ERROR: one or more container images in ci/main.go could not be resolved."
echo "Fix the image tag before committing."
exit 1
fi
+8
View File
@@ -23,6 +23,8 @@ const _noCode = {
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/models/note.dart',
'lib/core/repositories/note_repository.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -41,6 +43,7 @@ const _excluded = {
'lib/ui/screens/account_send_screen.dart',
'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/address_emails_screen.dart',
'lib/ui/screens/bug_report_screen.dart',
'lib/ui/screens/changelog_screen.dart',
'lib/ui/screens/combined_inbox_screen.dart',
'lib/ui/screens/compose_screen.dart',
@@ -54,6 +57,7 @@ const _excluded = {
'lib/ui/screens/sieve_scripts_screen.dart',
'lib/ui/screens/sync_log_screen.dart',
'lib/ui/screens/thread_detail_screen.dart',
'lib/ui/screens/undo_log_detail_screen.dart',
'lib/ui/screens/undo_log_screen.dart',
'lib/ui/widgets/folder_drawer.dart',
'lib/ui/widgets/secure_email_webview.dart',
@@ -80,6 +84,10 @@ const _excluded = {
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart',
'lib/ui/widgets/email_thread_tile.dart',
'lib/ui/screens/trusted_image_senders_screen.dart',
'lib/data/repositories/note_repository_impl.dart',
'lib/ui/widgets/thread_tile.dart',
};
void main() {
+5 -1
View File
@@ -34,7 +34,7 @@ _filter_noise() {
_run() {
: > "$OUT" ; : > "$RC_FILE"
{
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
--project-id "$FIREBASE_PROJECT_ID"
echo $? > "$RC_FILE"
@@ -44,6 +44,10 @@ _run() {
for attempt in 1 2 3; do
_run && break
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
if [ "$RC" -eq 124 ]; then
echo "::warning::[firebase] attempt $attempt/3 timed out after 2400s" >&2
exit 124
fi
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
else
+30 -4
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
[ "${CI:-}" = "true" ] || [ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set."
@@ -16,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
# must be masked line-by-line because ::add-mask:: covers one line at a time.
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
while IFS= read -r line; do
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
done <<< "$DAGGER_SSH_KEY"
# Export all CI secrets to the GitHub Actions environment so subsequent steps
# can use them without referencing Forgejo secrets directly.
export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
# Register each non-empty line for log redaction in the Actions runner.
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
while IFS= read -r line; do
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
done <<< "$value"
fi
if [ -n "${GITHUB_ENV:-}" ]; then
# Use heredoc syntax for multiline-safe export.
# Avoid adding a second trailing newline for values that already end with one
@@ -50,16 +64,28 @@ export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
rm -f ~/.ssh/dagger_key
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
# Add remote host to known_hosts
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
_t0=$SECONDS
timeout 30 ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
fi
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
# Create a background SSH tunnel to the Dagger engine Unix socket.
# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host,
# eliminating the need for a socat bridge on the server side.
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
_t0=$SECONDS
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::SSH tunnel setup took ${_elapsed}s"
fi
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
+266
View File
@@ -0,0 +1,266 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
// BugReport represents the data stored in report.json
type BugReport struct {
Description string `json:"description"`
AboutInfo string `json:"about_info"`
EmailData string `json:"email_data,omitempty"`
SyncLog string `json:"sync_log,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
var (
rateLimitMu sync.Mutex
requestTimes []time.Time
)
// checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally.
func checkRateLimit() (bool, time.Duration) {
rateLimitMu.Lock()
defer rateLimitMu.Unlock()
now := time.Now()
// Clean up timestamps older than 1 minute
var valid []time.Time
for _, t := range requestTimes {
if now.Sub(t) < time.Minute {
valid = append(valid, t)
}
}
requestTimes = valid
if len(requestTimes) >= 10 {
// Calculate time until the oldest request in the window falls out of it
oldest := requestTimes[0]
remaining := time.Minute - now.Sub(oldest)
if remaining < 0 {
remaining = 0
}
return false, remaining
}
requestTimes = append(requestTimes, now)
return true, 0
}
func generateUUID() (string, error) {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
return "", err
}
// Format as UUID v4 structure
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
}
func bugReportHandler(storageDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Enable CORS so the web app (if applicable) can upload
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting check
allowed, waitTime := checkRateLimit()
if !allowed {
retryAfter := int(waitTime.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."})
return
}
// Limit body size to 20 MB (20 * 1024 * 1024 bytes)
const maxBodySize = 20 * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
// Parse the multipart form
err := r.ParseMultipartForm(maxBodySize)
if err != nil {
log.Printf("Failed to parse multipart form: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."})
return
}
defer func() {
_ = r.MultipartForm.RemoveAll()
}()
description := r.FormValue("description")
aboutInfo := r.FormValue("about_info")
if description == "" || aboutInfo == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."})
return
}
email := r.FormValue("email")
emailData := r.FormValue("email_data")
syncLog := r.FormValue("sync_log")
uuidVal, err := generateUUID()
if err != nil {
log.Printf("Failed to generate UUID: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
now := time.Now()
timestampStr := now.Format("20060102_150405")
dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal)
reportDir := filepath.Join(storageDir, dirName)
err = os.MkdirAll(reportDir, 0750)
if err != nil {
log.Printf("Failed to create report directory: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write report.json
report := BugReport{
Description: description,
AboutInfo: aboutInfo,
EmailData: emailData,
SyncLog: syncLog,
Timestamp: now,
}
reportJSONPath := filepath.Join(reportDir, "report.json")
reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Printf("Failed to create report.json: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer reportJSONFile.Close()
enc := json.NewEncoder(reportJSONFile)
enc.SetIndent("", " ")
err = enc.Encode(report)
if err != nil {
log.Printf("Failed to write report.json: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write contact email to mail.eml (kept separate from report.json to isolate PII)
if email != "" {
mailEmlPath := filepath.Join(reportDir, "mail.eml")
err = os.WriteFile(mailEmlPath, []byte(email), 0600)
if err != nil {
log.Printf("Failed to write mail.eml: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Save attachments
form := r.MultipartForm
files := form.File["attachments[]"]
for i, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
log.Printf("Failed to open attachment %d: %v", i, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer file.Close()
// Sanitize filename to avoid directory traversal
baseName := filepath.Base(fileHeader.Filename)
attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName)
attachmentPath := filepath.Join(reportDir, attachmentName)
destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Printf("Failed to create attachment file %s: %v", attachmentName, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer destFile.Close()
_, err = io.Copy(destFile, file)
if err != nil {
log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal})
}
}
func main() {
port := os.Getenv("BUGREPORT_PORT")
if port == "" {
port = "8090"
}
storageDir := os.Getenv("BUGREPORT_STORAGE_DIR")
if storageDir == "" {
storageDir = "./reports"
}
// Create storage directory if it doesn't exist
err := os.MkdirAll(storageDir, 0750)
if err != nil {
log.Fatalf("Failed to create storage directory %s: %v", storageDir, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir))
addr := net.JoinHostPort("127.0.0.1", port)
log.Printf("Bug report server starting on %s...", addr)
log.Printf("Reports storage directory: %s", storageDir)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
+1
View File
@@ -7,6 +7,7 @@
# Run inside nix develop:
# stalwart-dev/integration_android_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
_SCRIPT_START=$(date +%s%3N)
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
+1
View File
@@ -5,6 +5,7 @@
#
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
# Timing helper: prints elapsed seconds since script start with a label.
_SCRIPT_START=$(date +%s%3N)
+1
View File
@@ -2,6 +2,7 @@
# Starts Stalwart in the background on fresh random ports, runs Flutter
# integration tests, then stops it.
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
@@ -169,6 +169,15 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
+221
View File
@@ -0,0 +1,221 @@
// Chaos monkey test — drives the email repository through random operations
// against a live Stalwart instance to surface crashes and data-corruption bugs.
//
// Run via: stalwart-dev/test.sh
//
// Environment variables:
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
import 'dart:io';
import 'dart:math';
import 'package:enough_mail/enough_mail.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' as email_model;
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:test/test.dart';
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
import '../unit/db_test_helper.dart';
String _env(String key, [String fallback = '']) =>
Platform.environment[key] ?? fallback;
Future<ImapClient> _imapConnectPlain(
Account account,
String username,
String password,
) async {
final client =
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
await client.connectToServer(
account.imapHost,
account.imapPort,
isSecure: false,
);
await client.login(username, password);
return client;
}
Future<SmtpClient> _smtpConnectPlain(
Account account,
String username,
String password,
) async {
final atIndex = account.email.lastIndexOf('@');
final domain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
final client = SmtpClient(domain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
isSecure: false,
);
await client.ehlo();
await client.authenticate(username, password);
return client;
}
Future<void> _clearMailbox(
Account account,
String userEmail,
String userPass,
String mailboxPath,
) async {
final client = await _imapConnectPlain(account, userEmail, userPass);
try {
final box = await client.selectMailboxByPath(mailboxPath);
if (box.messagesExists == 0) return;
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return;
final seq = MessageSequence.fromIds(uids, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
} finally {
await client.logout();
}
}
void main() {
late String imapHost;
late int imapPort;
late String smtpHost;
late int smtpPort;
late String userEmail;
late String userPass;
late Account account;
late AppDatabase db;
late EmailRepositoryImpl emails;
setUpAll(configureSqliteForTests);
setUp(() async {
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
userEmail = _env('STALWART_USER_B', 'alice@example.com');
userPass = _env('STALWART_PASS_B', 'secret');
account = Account(
id: 'chaos',
displayName: 'Chaos',
email: userEmail,
imapHost: imapHost,
imapPort: imapPort,
imapSsl: false,
smtpHost: smtpHost,
smtpPort: smtpPort,
);
db = openTestDatabase();
final secureStorage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, secureStorage);
await accounts.addAccount(account, userPass);
emails = EmailRepositoryImpl(
db,
accounts,
imapConnect: _imapConnectPlain,
smtpConnect: _smtpConnectPlain,
);
await _clearMailbox(account, userEmail, userPass, 'INBOX');
});
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
() async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
: int.parse(seedStr);
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
final rng = Random(seed);
stdout.writeln('chaos-monkey: seed=$seed rounds=$rounds');
// Seed INBOX with a few messages so early rounds have something to act on.
for (var i = 0; i < 3; i++) {
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: 'seed-$i',
body: 'Seed email $i.',
),
);
}
await emails.syncEmails(account.id, 'INBOX');
for (var round = 0; round < rounds; round++) {
final action = rng.nextInt(8);
stdout.writeln('chaos-monkey: round=$round action=$action');
switch (action) {
case 0: // sync INBOX
await emails.syncEmails(account.id, 'INBOX');
case 1: // sync Sent
await emails.syncEmails(account.id, 'Sent');
case 2: // send email to self
final subject = 'chaos-$round-${rng.nextInt(9999)}';
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: subject,
body: 'Round $round. Value: ${rng.nextInt(1000000)}.',
),
);
case 3: // mark random email seen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: true);
case 4: // mark random email unseen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: false);
case 5: // toggle flagged on random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, flagged: !e.isFlagged);
case 6: // flush pending changes to server
final flushed =
await emails.flushPendingChanges(account.id, userPass);
stdout.writeln('chaos-monkey: flushed $flushed pending changes');
case 7: // delete random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.deleteEmail(e.id);
}
}
// Final flush and sync to confirm the server is in a consistent state.
final flushed = await emails.flushPendingChanges(account.id, userPass);
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
final result = await emails.syncEmails(account.id, 'INBOX');
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
});
}
@@ -421,6 +421,7 @@ void main() {
final r = makeRepo();
await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX');
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
expect(results, hasLength(1));
@@ -432,6 +433,7 @@ void main() {
final r = makeRepo();
await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX');
final results = await r.emails.searchEmails(
'test',
+43
View File
@@ -0,0 +1,43 @@
// Loads Material fonts (Roboto + MaterialIcons) before any test runs so that
// golden/screenshot tests render real text instead of placeholder boxes.
//
// Flutter widget tests don't load fonts by default. This file is discovered
// automatically by `flutter test` for every test under test/.
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
setUpAll(_loadMaterialFonts);
await testMain();
}
Future<void> _loadMaterialFonts() async {
// Locate Flutter's cached material fonts relative to the flutter_tester executable.
// Layout: <flutter-root>/bin/cache/artifacts/engine/linux-x64/flutter_tester
// <flutter-root>/bin/cache/artifacts/material_fonts/
final cacheDir =
File(Platform.resolvedExecutable).parent.parent.parent.parent;
final fontsDir = '${cacheDir.path}/artifacts/material_fonts';
Future<ByteData> load(String name) async {
final bytes = await File('$fontsDir/$name').readAsBytes();
return ByteData.view(bytes.buffer);
}
await (FontLoader('Roboto')
..addFont(load('Roboto-Regular.ttf'))
..addFont(load('Roboto-Medium.ttf'))
..addFont(load('Roboto-Bold.ttf'))
..addFont(load('Roboto-Italic.ttf'))
..addFont(load('Roboto-MediumItalic.ttf'))
..addFont(load('Roboto-BoldItalic.ttf')))
.load();
await (FontLoader('MaterialIcons')
..addFont(load('MaterialIcons-Regular.otf')))
.load();
}
+427
View File
@@ -0,0 +1,427 @@
// Generates Play Store promotional screenshots for all three device classes.
//
// Run with:
// fvm flutter test test/screenshot_automation_test.dart --update-goldens
//
// Output: screenshots/{phone,tablet_7in,tablet_10in}/{light,dark}/<scene>.png
// at the repository root (one directory above test/).
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.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/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'widget/helpers.dart';
// ---------------------------------------------------------------------------
// Device configurations
// ---------------------------------------------------------------------------
typedef _Device = ({String name, double width, double height, double dpr});
const _devices = <_Device>[
(name: 'phone', width: 1080.0, height: 1920.0, dpr: 3.0),
(name: 'tablet_7in', width: 1200.0, height: 1920.0, dpr: 2.0),
(name: 'tablet_10in', width: 1600.0, height: 2560.0, dpr: 2.0),
];
// ---------------------------------------------------------------------------
// Sample data — fixed date so golden files are stable between runs
// ---------------------------------------------------------------------------
const _kAccount = Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@sharedinbox.de',
imapHost: 'imap.sharedinbox.de',
smtpHost: 'smtp.sharedinbox.de',
);
final _kDate = DateTime(2025, 5, 14, 10, 30);
Email _email({
required String id,
required String subject,
required String fromName,
required String fromEmail,
bool isSeen = true,
bool isFlagged = false,
bool hasAttachment = false,
String? preview,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: [EmailAddress(name: fromName, email: fromEmail)],
to: const [EmailAddress(name: 'Alice', email: 'alice@sharedinbox.de')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
preview: preview,
);
final _sampleEmails = [
_email(
id: 'acc-1:1',
subject: 'Re: Project kick-off next week',
fromName: 'Maria Hoffmann',
fromEmail: 'maria@corp.example',
isSeen: false,
preview: 'Sounds great! I will prepare the slides beforehand.',
),
_email(
id: 'acc-1:2',
subject: 'Your invoice #2024-0312 is ready',
fromName: 'Billing',
fromEmail: 'billing@service.example',
isSeen: false,
preview: 'Your invoice for May is attached as a PDF.',
),
_email(
id: 'acc-1:3',
subject: 'Team lunch — Friday 12:30',
fromName: 'Thomas Müller',
fromEmail: 'thomas@corp.example',
isFlagged: true,
preview: 'The Italian place on Main Street. RSVP by Thursday please.',
),
_email(
id: 'acc-1:4',
subject: 'Quarterly review agenda',
fromName: 'HR Team',
fromEmail: 'hr@corp.example',
preview:
"Please find the agenda for next week's quarterly review attached.",
),
_email(
id: 'acc-1:5',
subject: 'Weekend hiking trip — photos inside',
fromName: 'Jonas Weber',
fromEmail: 'jonas@personal.example',
hasAttachment: true,
preview: 'Had such a great time! Here are the photos from Saturday.',
),
_email(
id: 'acc-1:6',
subject: 'Reminder: dentist appointment tomorrow',
fromName: 'City Dental',
fromEmail: 'noreply@citydental.example',
preview: 'Your appointment is confirmed for Thursday at 14:00.',
),
_email(
id: 'acc-1:7',
subject: 'Re: Feedback on the draft',
fromName: 'Laura Schmidt',
fromEmail: 'laura@corp.example',
isSeen: false,
preview: 'I left some comments on page 3. Overall it looks really solid!',
),
_email(
id: 'acc-1:8',
subject: 'Flight confirmation PNR XYZ123',
fromName: 'Sunshine Airlines',
fromEmail: 'noreply@airline.example',
preview:
'Your booking is confirmed. Check-in opens 24 hours before departure.',
),
];
final _sampleMailboxes = [
const Mailbox(
id: 'acc-1:INBOX',
accountId: 'acc-1',
path: 'INBOX',
name: 'INBOX',
role: 'inbox',
unreadCount: 3,
totalCount: 8,
),
const Mailbox(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
role: 'sent',
unreadCount: 0,
totalCount: 42,
),
const Mailbox(
id: 'acc-1:Drafts',
accountId: 'acc-1',
path: 'Drafts',
name: 'Drafts',
role: 'drafts',
unreadCount: 0,
totalCount: 1,
),
const Mailbox(
id: 'acc-1:Trash',
accountId: 'acc-1',
path: 'Trash',
name: 'Trash',
role: 'trash',
unreadCount: 0,
totalCount: 7,
),
];
// Email shown in the detail scene.
final _detailEmail = _email(
id: 'acc-1:1',
subject: 'Re: Project kick-off next week',
fromName: 'Maria Hoffmann',
fromEmail: 'maria@corp.example',
);
const _detailBody = EmailBody(
emailId: 'acc-1:1',
attachments: [],
textBody: 'Hi Alice,\n\n'
'Sounds great! I will prepare the slides beforehand so we have '
'something concrete to discuss.\n\n'
'Looking forward to meeting everyone!\n\n'
'Best,\nMaria',
);
// Emails shown when the user searches for "invoice".
final _searchResults = [
_email(
id: 'acc-1:2',
subject: 'Your invoice #2024-0312 is ready',
fromName: 'Billing',
fromEmail: 'billing@service.example',
isSeen: false,
),
_email(
id: 'acc-1:9',
subject: 'Invoice for March services',
fromName: 'Cloud Services',
fromEmail: 'noreply@cloud.example',
),
];
// ---------------------------------------------------------------------------
// Provider override sets for each scene
// ---------------------------------------------------------------------------
List<Override> _inboxOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: _sampleEmails),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _detailOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: _sampleEmails,
emailDetail: _detailEmail,
emailBody: _detailBody,
),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _composeOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: _sampleEmails),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _mailboxOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _searchOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: _sampleEmails,
searchResults: _searchResults,
),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
// ---------------------------------------------------------------------------
// Tests — 3 devices × 2 themes × 5 scenes = 30 golden files
// ---------------------------------------------------------------------------
void main() {
for (final device in _devices) {
for (final themeMode in [ThemeMode.light, ThemeMode.dark]) {
final themeName = themeMode == ThemeMode.light ? 'light' : 'dark';
// Golden files are stored relative to this test file (test/).
// The ../ prefix places them at repo root under screenshots/.
final dir = '../screenshots/${device.name}/$themeName';
final prefix = '${device.name}_$themeName';
group('${device.name}/$themeName', () {
void setDevice(WidgetTester tester) {
tester.view.physicalSize = Size(device.width, device.height);
tester.view.devicePixelRatio = device.dpr;
addTearDown(tester.view.reset);
}
testWidgets('inbox_list', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _inboxOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_inbox_list.png'),
);
});
testWidgets('email_detail', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
// The colon in "acc-1:1" must be percent-encoded in the URL.
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A1',
overrides: _detailOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_email_detail.png'),
);
});
testWidgets('compose', (tester) async {
setDevice(tester);
// Start at the inbox, then navigate to compose with pre-fill extras
// so GoRouter can pass them to ComposeScreen via state.extra.
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _composeOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
GoRouter.of(tester.element(find.byType(EmailListScreen))).go(
'/compose',
extra: <String, dynamic>{
'accountId': 'acc-1',
'prefillTo': 'thomas@corp.example',
'prefillSubject': 'Re: Team lunch — Friday 12:30',
'prefillBody':
'Hi Thomas,\n\nCount me in! See you on Friday.\n\nBest,\nAlice',
},
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_compose.png'),
);
});
testWidgets('mailbox_list', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes',
overrides: _mailboxOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_mailbox_list.png'),
);
});
testWidgets('search_results', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _searchOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'invoice');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_search_results.png'),
);
});
});
}
}
}
+9
View File
@@ -239,6 +239,15 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
@@ -235,6 +235,31 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
),
)),
) as _i5.Future<_i2.Mailbox>);
@override
_i5.Future<_i2.Mailbox> createMailbox(
String? accountId,
String? name,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
}
/// A class which mocks [EmailRepository].
+123
View File
@@ -453,6 +453,129 @@ void main() {
expect(results.first.subject, 'foobar baz');
});
test('searchEmails filters by mailboxPath using local FTS5', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Insert matching email in INBOX.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Meeting agenda'),
receivedAt: DateTime(2024),
),
);
// Insert matching email in a different mailbox — must not appear.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
subject: const Value('Meeting follow-up'),
receivedAt: DateTime(2024),
),
);
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
expect(results, hasLength(1));
expect(results.first.subject, 'Meeting agenda');
expect(results.first.mailboxPath, 'INBOX');
});
test('searchEmailsGlobal includes emails matched by note text', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Email whose subject does NOT match — but its note does.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
messageId: const Value('<msg1@example.com>'),
subject: const Value('Weekly report'),
receivedAt: DateTime(2024),
),
);
// Add a note referencing the email's messageId.
await r.db.into(r.db.emailNotes).insert(
EmailNotesCompanion.insert(
id: 'note-1',
accountId: 'acc-1',
messageId: '<msg1@example.com>',
noteText: 'Urgent follow-up needed',
serverId: '42',
createdAt: DateTime(2024),
),
);
final results =
await r.emails.searchEmailsGlobal(null, 'urgent');
expect(results, hasLength(1));
expect(results.first.subject, 'Weekly report');
});
test('searchEmails includes emails matched by note text in mailbox',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
messageId: const Value('<msg1@example.com>'),
subject: const Value('Project update'),
receivedAt: DateTime(2024),
),
);
// Email in a different mailbox — its note must not appear in INBOX search.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
messageId: const Value('<msg2@example.com>'),
subject: const Value('Other email'),
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emailNotes).insert(
EmailNotesCompanion.insert(
id: 'note-1',
accountId: 'acc-1',
messageId: '<msg1@example.com>',
noteText: 'remember to call client',
serverId: '42',
createdAt: DateTime(2024),
),
);
await r.db.into(r.db.emailNotes).insert(
EmailNotesCompanion.insert(
id: 'note-2',
accountId: 'acc-1',
messageId: '<msg2@example.com>',
noteText: 'remember to call client',
serverId: '43',
createdAt: DateTime(2024),
),
);
final results =
await r.emails.searchEmails('acc-1', 'INBOX', 'client');
expect(results, hasLength(1));
expect(results.first.subject, 'Project update');
expect(results.first.mailboxPath, 'INBOX');
});
test(
'searchAddresses returns results sorted by most recently used',
() async {
+60 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 37);
expect(db.schemaVersion, 40);
await db.close();
});
@@ -420,12 +420,22 @@ void main() {
.customSelect('SELECT count(*) FROM image_trusted_senders')
.get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
// v40: installed_versions table.
await db.customSelect('SELECT count(*) FROM installed_versions').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 37', () async {
test('fresh install creates all tables at schemaVersion 40', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -454,6 +464,8 @@ void main() {
'local_sieve_applied', // v32
'user_preferences', // v34
'image_trusted_senders', // v37
'email_notes', // v39
'installed_versions', // v40
]),
);
@@ -485,7 +497,53 @@ void main() {
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
// v40: installed_versions table.
await db.customSelect('SELECT count(*) FROM installed_versions').get();
await db.close();
});
});
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/508:
// _openConnection's setup callback must not crash when PRAGMA journal_mode =
// WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5)
// because a WorkManager background task already has the DB open in WAL mode.
group('WAL setup (#508)', () {
test(
'setupPragmasForTesting does not throw when WAL is already active and '
'another connection holds an open read transaction',
() {
final dbFile = File('test_wal_busy_508.db');
if (dbFile.existsSync()) dbFile.deleteSync();
addTearDown(() {
if (dbFile.existsSync()) dbFile.deleteSync();
});
// conn1: enable WAL and keep a read transaction open — simulates a
// WorkManager background task that opened the DB before the foreground
// app starts.
final conn1 = sqlite.sqlite3.open(dbFile.path);
conn1.execute('PRAGMA journal_mode = WAL;');
conn1.execute('BEGIN;');
conn1.select('SELECT 1;');
// conn2: run the exact production setup through setupPragmasForTesting.
// This must not throw even though conn1 holds an open transaction and
// the DB is already in WAL mode.
final conn2 = sqlite.sqlite3.open(dbFile.path);
expect(() => setupPragmasForTesting(conn2), returnsNormally);
conn1.execute('ROLLBACK;');
conn1.close();
conn2.close();
},
);
});
}
@@ -77,6 +77,15 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
+9
View File
@@ -67,6 +67,15 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
+6 -4
View File
@@ -86,9 +86,11 @@ void main() {
expect(find.textContaining('DB Schema Version'), findsWidgets);
// Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget);
expect(find.text('Copy to clipboard'), findsOneWidget);
expect(find.text('Create issue'), findsOneWidget);
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
expect(find.byIcon(Icons.feedback_outlined), findsOneWidget);
expect(find.text('Copy info'), findsOneWidget);
expect(find.text('Public issue'), findsOneWidget);
expect(find.text('Report bug'), findsOneWidget);
});
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
@@ -193,7 +195,7 @@ void main() {
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.bug_report));
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.pumpAndSettle();
expect(
+75 -10
View File
@@ -1,8 +1,12 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends CachingAssetBundle {
@@ -19,16 +23,33 @@ class _FakeAssetBundle extends CachingAssetBundle {
}
}
Widget _buildScreen({
required Map<String, String> assets,
Map<String, DateTime> installedVersions = const {},
}) {
return ProviderScope(
overrides: [
dbProvider.overrideWith((ref) {
final db = AppDatabase(NativeDatabase.memory());
ref.onDispose(db.close);
return db;
}),
installedVersionsProvider.overrideWith((ref) async => installedVersions),
],
child: DefaultAssetBundle(
bundle: _FakeAssetBundle(assets),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
}
const _fakeChangelog =
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
void main() {
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
child: const MaterialApp(home: ChangeLogScreen()),
),
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
);
await tester.pumpAndSettle();
@@ -41,14 +62,58 @@ void main() {
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(
DefaultAssetBundle(
bundle: _FakeAssetBundle({}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpWidget(_buildScreen(assets: {}));
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
testWidgets('ChangeLogScreen injects install marker for a known hash', (
tester,
) async {
const changelog =
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
final installedAt = DateTime(2024, 6, 15, 14, 32);
await tester.pumpWidget(
_buildScreen(
assets: {'assets/changelog.txt': changelog},
installedVersions: {'abc1234': installedAt},
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Installed: 14:32'), findsOneWidget);
expect(find.textContaining('15 Jun 2024'), findsOneWidget);
expect(find.textContaining('initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen shows no markers when no version recorded', (
tester,
) async {
const changelog =
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
await tester.pumpWidget(
_buildScreen(assets: {'assets/changelog.txt': changelog}),
);
await tester.pumpAndSettle();
expect(find.textContaining('Installed:'), findsNothing);
expect(find.textContaining('initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
tester,
) async {
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
await tester.pumpWidget(
_buildScreen(assets: {'assets/changelog.txt': changelog}),
);
await tester.pumpAndSettle();
// The link text "#42" must be visible in the rendered output.
expect(find.textContaining('#42'), findsOneWidget);
});
}
@@ -1,3 +1,6 @@
@Tags(['golden'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
+228 -24
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -102,30 +104,6 @@ void main() {
expect(find.byIcon(Icons.star), findsOneWidget);
});
testWidgets('tapping search icon shows search bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search…'), findsOneWidget);
});
testWidgets('submitting a search query shows "No results" when empty', (
tester,
) async {
@@ -430,6 +408,230 @@ void main() {
expect(find.text('Result email'), findsWidgets);
});
testWidgets(
'tapping first of multiple search results opens the first email',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
searchResults: [email1, email2],
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
// Tap the first result.
await tester.tap(find.text('Alpha Match'));
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Alpha Match'),
),
findsOneWidget,
);
// The second email's subject must not appear in the detail view.
expect(
find.descendant(
of: find.byType(EmailDetailScreen),
matching: find.text('Beta Match'),
),
findsNothing,
);
},
);
testWidgets(
'stale search results from a slower concurrent search are discarded',
(tester) async {
// Reproduces: user types quickly, triggering multiple concurrent IMAP
// searches. An older, slower search must not overwrite the results for
// the user's current query (issue #467).
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
// The first search call is held open by a Completer; all subsequent
// calls resolve immediately with freshEmail.
final staleCompleter = Completer<List<Email>>();
var firstCall = true;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) {
if (firstCall) {
firstCall = false;
return staleCompleter.future;
}
return Future.value([freshEmail]);
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Trigger the first (slow) search.
await tester.enterText(find.byType(TextField), 'slow');
await tester.testTextInput.receiveAction(TextInputAction.search);
// Do not pumpAndSettle yet — the slow search is still in flight.
// Trigger the second (fast) search by changing the query.
await tester.enterText(find.byType(TextField), 'fast');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); // fast searches settle immediately
// The fresh results must be shown.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
// Now let the stale search complete.
staleCompleter.complete([staleEmail]);
await tester.pumpAndSettle();
// The stale results must NOT replace the fresh ones.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
},
);
testWidgets(
'pressing Enter on already-settled search does not re-run search (issue #473)',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
var searchCallCount = 0;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
searchCallCount++;
return [email1, email2];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Run the initial search.
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
final countAfterFirstSearch = searchCallCount;
// Re-focus the search bar (simulates user tapping back into the field
// with the keyboard still visible) and press Enter again on the same,
// already-settled query.
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// The search must NOT re-run; call count must not increase.
expect(
searchCallCount,
countAfterFirstSearch,
reason:
'Enter on settled results must not re-run the search (issue #473)',
);
// Results must still be visible — no loading spinner.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Alpha Match'), findsOneWidget);
},
);
testWidgets(
'folder search returns results from local cache without any network call',
(tester) async {
// Verifies that searchEmails is backed by local SQLite (not IMAP).
// The repository throws if a network call is attempted, yet search
// must still return results.
final email = testEmail(subject: 'Cached subject');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
// Local DB: return cached results immediately.
return [email];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Cached');
await tester.pumpAndSettle();
expect(find.text('Cached subject'), findsOneWidget);
},
);
testWidgets('deleting all search results pops back to previous screen', (
tester,
) async {
@@ -586,6 +788,8 @@ void main() {
// Delete the email from the detail screen.
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
await tester.pump();
await tester.pumpAndSettle();
// Should have popped all the way back to the mailbox list.
expect(find.byType(EmailDetailScreen), findsNothing);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 72 KiB

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