Compare commits

...
Author SHA1 Message Date
Thomas SharedInbox e9f5f8074f ci: trigger CI after double-run fix 2026-06-04 02:57:16 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ae2b3fc715 fix: bump CI Flutter image from 3.44.0 to 3.44.1 to match .fvmrc
.fvmrc was updated to Flutter 3.44.1 in d7a9c2b but ci/main.go still
referenced 3.44.0. The version mismatch meant local dart format (3.44.1)
and CI dart format (3.44.0) could disagree, causing format-check failures
like the one in CI run #1449.

Also update the stale 3.41.6 version in the Graph() diagram.

Closes #383

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 02:47:12 +02:00
Bot of Thomas Güttler c1d314a621 feat: combined inbox as the default startup view (#376) (#379) 2026-06-04 02:46:59 +02:00
fa5938c7bd fix: silence Dagger output in deploy tasks, only show on failure (#390)
## Summary

- Wraps the \`dagger call\` in \`deploy-linux\`, \`publish-android\`, and \`deploy-apk\` Taskfile tasks with \`scripts/silent_on_success.sh\`
- On success: no Dagger output is printed (eliminates the verbose logs seen in deploy.yml CI runs)
- On failure: full Dagger output is replayed so errors remain visible

The project already uses \`scripts/silent_on_success.sh\` for other noisy commands (fvm, flutter pub get, build_runner, etc.) — this applies the same pattern to the three deploy tasks called from \`.forgejo/workflows/deploy.yml\`.

Closes #389

## Test plan

- [ ] Verify deploy CI run produces significantly less output on success
- [ ] Verify that on a failure, the full Dagger output is still printed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/390
2026-06-04 02:36:20 +02:00
f92f3debd7 feat: pre-fetch next email body to eliminate loading delay after delete (#381)
## Summary

- When viewing an email and then deleting (or archiving/moving/snoozing) it, the app navigates to the next email in the thread list.
- `getEmailBody` fetches from the network on a cache miss, causing the hourglass / loading spinner the issue describes.
- `EmailDetailNotifier` now fires a background `getEmailBody` call for the next thread's `latestEmailId` as soon as the current email finishes loading.
- `getEmailBody` already caches results in the `EmailBodies` table with a 7-day TTL, so by the time the user triggers a navigation action the body is pre-warmed and renders instantly.

## What changed

`lib/di.dart` — `EmailDetailNotifier.build()` calls `_prefetchNextEmailBody` (fire-and-forget via `unawaited`) after loading the current email. The helper respects the `afterMailViewAction` user preference: if set to `showMailbox` it does nothing.

## Test plan

- [ ] Open an email, delete it — next email should appear without the spinner
- [ ] Verify the same for archive, move, and snooze actions
- [ ] Verify behaviour is unchanged when `afterMailViewAction` is set to `showMailbox`
- [ ] Verify the last email in the list still pops back to the mailbox list correctly

Closes #367

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/381
2026-06-04 01:42:16 +02:00
692fa14d4d feat: remember show images per sender (#378)
## Summary

Closes #377

- Adds a new `ImageTrustedSenders` Drift table (schema v37) that stores email addresses for which remote images are loaded automatically (per device, not per account)
- When the user taps "Load remote images", the sender's address is saved and a 3-second snackbar appears with a "Settings" hyperlink to undo the choice in preferences
- Both `EmailDetailScreen` and `ThreadDetailScreen` check the trusted senders list on open and auto-load images for known senders
- The Preferences screen gains a new "Trusted image senders" section listing all saved senders with individual remove buttons

## Test plan

- [x] `dart run build_runner build` regenerates `database.g.dart` cleanly (schema v37)
- [x] `flutter analyze` — no issues
- [x] Migration test updated: checks `image_trusted_senders` table exists after upgrade and fresh install
- [x] `FakeUserPreferencesRepository` updated with three new interface methods
- [x] All 490 unit + widget tests pass (1 pre-existing golden test failure unrelated to this change)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/378
2026-06-04 01:41:50 +02:00
5e029a1365 feat: prioritise sent-folder addresses in To/Cc/Bcc autocomplete (#380)
## What changed

`searchAddresses` (used by the To/Cc/Bcc autocomplete) now runs two passes over the candidate email rows:

1. **Sent-folder rows first** — the mailboxes table is queried for mailboxes with `role='sent'`; any email row whose `mailboxPath` matches gets processed before inbox/other rows. Within this group addresses are ordered by `receivedAt` DESC as before.
2. **All other rows** — processed after sent rows, also by `receivedAt` DESC.

Within sent-folder rows, `toAddresses` and `ccJson` are checked before `fromJson` (the sender in a sent email is our own address, not a useful suggestion). For non-sent rows the original order (`fromJson`, `toAddresses`, `ccJson`) is kept.

This means: if you wrote to `info@foo.de` yesterday and received spam from `info@spam.de` today, typing "i" surfaces `info@foo.de` first.

## How verified

- All 492 unit tests pass (`task test`).
- Added a dedicated test `searchAddresses prioritises sent-folder addresses over newer received` that inserts an older sent email and a newer received email matching the same query prefix and asserts the sent-folder address is returned first.

Closes #375

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/380
2026-06-04 00:27:04 +02:00
87244de7da feat: group email headers in full-screen dialog (#374)
Closes #372

## What changed

- **New widget** `lib/ui/widgets/email_headers_dialog.dart`: full-screen header browser that organises headers into collapsible groups:
  - **Headers** — all standard headers (expanded by default)
  - **List- Headers** — all `List-*` headers grouped together (expanded)
  - **Received** — all `Received` headers, **collapsed by default**; shows the inter-hop duration between consecutive entries and highlights delays in colour (green < 30 s, orange < 5 min, red >= 5 min)
  - **ARC- Headers** — all `ARC-*` headers (above X-, expanded)
  - **X-Prefix Headers** — X- headers split by their second component (e.g. `X-Google-*` → "X-Google Headers"), sorted alphabetically, at the very bottom

- **`email_detail_screen.dart`**: `_showHeaders` now uses `EmailHeadersDialog`; `_showStructure` converted from `AlertDialog` to `Dialog.fullscreen()` — satisfying "Make popup windows full screen."

- **`scripts/check_coverage.dart`**: new widget file added to the `_excluded` set (UI widgets are covered by integration tests, not unit tests).

## Verified

`task check` passes (analyze: no issues, 491 unit tests pass, coverage >= 80 %).

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/374
2026-06-03 22:14:14 +02:00
6d1df2d213 fix: disable Renovate gomod updates for ci/ to prevent artifact failures (#370)
## What

PR #356 (Renovate) was blocked with `renovate/artifacts` — \"Artifact file update failure\" — because `ci/go.sum` could not be updated automatically.

**Root cause**: `ci/main.go` imports `dagger/ci/internal/dagger` (generated by `dagger develop`, not committed to the repo). Without that generated package present, `go mod tidy` cannot resolve the full dependency graph, so Renovate's artifact update step always fails.

The actual OpenTelemetry version bump from PR #356 was already applied manually in PR #363.

## Fix

Adds a `packageRule` to `renovate.json` to disable the `gomod` manager for `ci/**`. Renovate will no longer open failing PRs for Go dependencies in the Dagger CI module; updates to `ci/go.mod` and `ci/go.sum` must be done manually (using `dagger develop && go mod tidy` inside `ci/`).

## Verification

- `renovate.json` validates against the Renovate schema.
- No Go or Drift schema changes; `task check` is unaffected.

Closes #368

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Co-authored-by: guettli <guettli@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/370
2026-06-03 22:13:43 +02:00
29c2c7e96c fix: three deploy failures from run #1424 (#369)
## Summary

Fixes three distinct failures from CI deploy run #1424 and concurrent website update failures.

- **Play Store job**: `pip install google-auth requests` fails on Ubuntu 24.04 with PEP 668. Fixed by using `python3 -m venv` for an isolated install.
- **SSH key error (APK, Linux, website jobs)**: All SSH/rsync steps fail with `Load key "/root/.ssh/id_ed25519": error in libcrypto` inside the Dagger Alpine 3.21 container. This is the first time these jobs actually ran (all previous deploy runs had every job skipped). Two fixes:
  - `setup_dagger_remote.sh`: `export_secret` was appending an extra trailing newline to values (like SSH private keys) that already end with `\n`. Now only adds one when needed.
  - `ci/main.go` `Deployer`: mounts the key at a `.raw` path, strips Windows-style CRLF endings with `tr -d '\r'`, then writes the normalised key to `id_ed25519`. CRLF bytes cause "error in libcrypto" in Alpine's LibreSSL-backed openssh.

## Test plan
- [ ] Deploy run triggers after merge; all three deploy jobs complete
- [ ] Play Store verification step passes
- [ ] SSH commands in Alpine load the key without `error in libcrypto`

Closes #366

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/369
2026-06-03 21:23:13 +02:00
6a097976d3 fix: correct LAST_DEPLOYED_SHA detection so Play Store always gets updated (#364)
Closes #361

Three bugs in the hourly deploy workflow's change-detection logic caused the Play Store to silently fall behind whenever a deploy failed or all-android jobs were skipped.

**Bug 1 (primary): commit_sha → head_sha**
Forgejo's API returns head_sha; commit_sha was always None. This meant LAST_DEPLOYED_SHA was always empty, so the diff fell back to HEAD~1..HEAD — only the single most recent commit was inspected. If android changes landed in an earlier commit, they were silently missed.

**Bug 2: Skipped runs counted as 'deployed'**
A workflow run where deploy-playstore was skipped (android=false) has status=success, so it was treated as a successful deploy. Now the code queries each run's job results and only trusts a run where the 'Build & Deploy to Play Store' job's own conclusion=success.

**Bug 3: Narrow fallback when SHA unknown**
When LAST_DEPLOYED_SHA could not be determined the workflow diffed HEAD~1..HEAD — potentially missing many commits. Now it defaults to android=true / linux=true (deploy everything) as the safe fallback.

Additional changes:
- ::error:: / ::warning:: / ::notice:: annotations so skip/failure reasons surface in the Actions UI.
- scripts/verify_playstore_deploy.py: new post-deploy check that queries the internal track and fails if the latest version code is more than 1 hour old. (Version codes are Unix timestamps set by ci/main.go's PublishAndroid.) Catches silent deploy failures the upload API did not reject.
- scripts/test_verify_playstore_deploy.py: 5 unit tests for the verify script (all pass).

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/364
2026-06-03 19:26:00 +02:00
d847d40ab0 fix: add Renovate custom managers for Dagger version in Dockerfile and DAGGER.md (#365)
Renovate only tracked the engine version in `ci/dagger.json`. This PR adds regex `customManagers` so Renovate also updates:
- `DAGGER_VERSION` in `.forgejo/Dockerfile`
- the nix flake reference (`github:dagger/nix/vX.Y.Z#dagger`) in `DAGGER.md`

All three now point to the same `dagger/dagger` GitHub releases datasource so they stay in sync via a single grouped PR.

Also bumps the stale `DAGGER.md` nix reference from `v0.11.4` to `v0.20.8` to match the current engine version.

Closes #358

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/365
2026-06-03 19:25:25 +02:00
Thomas SharedInbox 761378f583 Dockerfile. 2026-06-03 17:30:30 +02:00
63da36c18a fix: update OpenTelemetry to v1.44.0 and fix go.sum inconsistency (#363)
## What

PR #356 (Renovate) was blocked with "Artifact file update failure" because `ci/go.sum` was out of sync with `ci/go.mod`.

**Root cause**: The `require` section listed otel log packages at v0.17.0 while `replace` directives pinned them to v0.19.0, but `go.sum` only had hashes for v0.16.0. Renovate couldn't auto-update go.sum because the Dagger module's `internal/dagger` generated package isn't in version control, so standard `go mod tidy` couldn't resolve the full dependency graph.

## Changes

- Bumps `go.opentelemetry.io/otel` + `otel/trace` + `otel/sdk` v1.43.0 → v1.44.0 (implementing PR #356's intent)
- Updates all related otel exporters and sub-packages to v1.44.0 / v0.20.0
- Aligns `replace` directives from v0.19.0 → v0.20.0 (consistent with require section)
- Also picks up `grpc` v1.79.3→v1.80.0 and `proto/otlp` v1.9.0→v1.10.0 (from `go mod tidy`)
- Adds all missing `h1:` and `/go.mod` hashes to `go.sum`

## Verification

- `go mod verify` passes
- Hashes fetched directly via `go mod download -json` from the official Go module proxy

Closes #359

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/363
2026-06-03 16:44:04 +02:00
d3bd8dba92 fix: pass commit hash to Hugo so website-verify.sh finds x-version (#362)
## Root cause

`BuildWebsite` and `PublishWebsite` in `ci/main.go` ran `hugo --minify` without setting the `HUGO_PARAMS_GITVERSION` environment variable. Hugo maps that env var to `site.Params.gitversion`, which the `website/layouts/_partials/extend_head.html` template uses to render `<meta name="x-version" content="...">` in the page `<head>`.

Without that meta tag, `website-verify.sh` (which greps for `x-version.*${VERSION}` in the live HTML) always timed out and reported failure — even though the site itself was deployed successfully.

## Fix

- Added an optional `commitHash` parameter to `BuildWebsite` and `PublishWebsite` in `ci/main.go`. When provided, it is passed to the Hugo container via `WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)` — consistent with how `BuildLinuxRelease` and friends already inject `GIT_HASH`.
- Updated `task publish-website` in `Taskfile.yml` to compute `HASH=$(git rev-parse --short HEAD)` and forward it as `--commit-hash "$HASH"` — matching the pattern used by `task deploy-linux`.

## Verification

- `gofmt` passes on the modified `ci/main.go`.
- The logic mirrors the existing `BuildLinuxRelease` pattern that already works in CI.

Closes #360

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/362
2026-06-03 16:43:26 +02:00
9605c5e3b7 ci: print explicit reason when deploy jobs are skipped (#357)
## Summary

- The \`Detect Changed Files\` step in \`deploy.yml\` previously set \`android=false\` / \`linux=false\` silently, leaving downstream jobs showing only "skipped" in CI with no visible cause
- Now each decision emits a clear one-liner in the step log:
  - \`Android deploy: SKIPPED (no android-relevant files changed)\`
  - \`Android deploy: TRIGGERED (android-relevant files changed)\`
  - \`Linux deploy: SKIPPED (no linux-relevant files changed)\`
  - or \`HEAD <sha> already successfully deployed — skipping all deploy jobs\`
- The skip reason is visible in the \`check-changes\` job output, which is the job that makes the decision

Closes #353

## Test plan

- [ ] Trigger the deploy workflow on a commit that only touches CI/docs files — \`check-changes\` step log should show "Android deploy: SKIPPED (no android-relevant files changed)"
- [ ] Trigger the deploy workflow on a commit touching \`lib/\` — log should show "Android deploy: TRIGGERED"
- [ ] Trigger a second run on the same commit — log should show "already successfully deployed — skipping all deploy jobs"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/357
2026-06-03 13:27:29 +02:00
1681fb9202 fix: fail fast in CI — parallel hygiene/layer checks, no spurious retries (#350)
## Summary

Closes #349

Two bugs prevented `check-dagger` from failing fast when checks failed:

- **Hygiene + Layers checked sequentially** — they are cheap structural checks with no dependency on each other. Running them in parallel (`errgroup.Group`) means failures are reported sooner.
- **Spurious retries from `errgroup.WithContext`** — the backend and integration tests previously shared a derived context via `errgroup.WithContext`. When one test failed, the context was cancelled, causing the sibling test to emit `"context canceled"` in Dagger's `--progress=plain` output. The `retry_dagger` function in `Taskfile.yml` matched that string as a transient network error and re-ran the entire pipeline up to 3 times — a real test failure could take 30+ minutes to be reported instead of ~10.

**Fix in `ci/main.go`:**
- Hygiene + layers now run in parallel with `errgroup.Group`
- Backend + integration tests now use `errgroup.Group` (no shared cancel context), so a failure in one does not emit `"context canceled"` for the other

**Fix in `Taskfile.yml`:**
- Removed `context canceled` from the `retry_dagger` grep pattern; the remaining patterns (`connection reset`, `context deadline exceeded`, `connection refused`, `invalid return status code`) still cover genuine network/engine transients

## Test plan

- [ ] Confirm the Forgejo CI run completes and, when a check fails, it fails fast (no 3× retry loop in logs)
- [ ] Verify `task check-dagger` still retries on actual connection errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Co-authored-by: guettli <guettli@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/350
2026-06-03 13:07:37 +02:00
guettlibotandguettli d7a9c2b4f8 chore(deps): update dependency flutter to v3.44.1 (#355)
This PR contains the following updates:

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

---

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

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/355
2026-06-03 08:21:25 +02:00
Bot of Thomas Güttler 2747c4e63d chore: migrate CI secrets from Forgejo to SOPS (#354) 2026-06-03 06:37:07 +02:00
dbc9d4dac8 fix: migrate jvmTarget to compilerOptions DSL for Kotlin 2.x (#352)
## Summary

- `android/app/build.gradle.kts` used `kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() }`, which Kotlin 2.x treats as a compilation error ("Using jvmTarget: String is an error")
- Replaced with the `compilerOptions` DSL using `org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17`

## Test plan

- [x] Confirmed root cause from CI run #1316 logs: `e: .../build.gradle.kts:20:9: Using 'jvmTarget: String' is an error`
- [ ] CI deploy workflow should now pass the Android bundle build step

Closes #351

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:20:48 +02:00
98 changed files with 2802 additions and 3939 deletions
+6 -2
View File
@@ -4,14 +4,18 @@
# In systemd service: # In systemd service:
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner # ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml # ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
FROM ghcr.io/catthehacker/ubuntu:go-24.04 FROM ghcr.io/catthehacker/ubuntu:go-24.04
# Infrastructure tools required by CI workflows # Infrastructure tools required by CI workflows
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
stunnel4 \ jq \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# SOPS
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
&& chmod +x /usr/local/bin/sops
# Dagger CLI — pinned to match the engine version on the runner host # Dagger CLI — pinned to match the engine version on the runner host
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \ RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh | DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
+3 -148
View File
@@ -1,159 +1,14 @@
name: CI name: CI
on: [push, pull_request]
on:
push:
branches: [main]
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
pull_request:
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
jobs: jobs:
check: check:
name: Full Project Check name: Full Project Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: - name: Setup Dagger Remote Engine
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Locate Docker daemon for local Dagger engine
run: |
# Skip if remote Dagger engine is already configured (preferred path)
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
echo "Remote Dagger engine configured, no local Docker needed."
exit 0
fi
# Try host Docker socket (DooD) if runner mounts it
if [ -S /var/run/docker.sock ]; then
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
echo "Docker available via host socket."
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
echo "CI will likely fail at the Dagger step." >&2
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite - name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger run: task check-dagger
- name: Prune Dagger cache after check
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+73 -61
View File
@@ -34,14 +34,17 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD) HEAD_SHA=$(git rev-parse HEAD)
# Skip if this exact commit was already successfully deployed (prevents # Find the most recent workflow run where deploy-playstore actually succeeded
# hourly schedule from redeploying the same commit on every tick). # (not merely skipped). Bug fix: previous code used commit_sha (always None in
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
# every run and the fallback diff to only cover HEAD~1..HEAD.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF' LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "") token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "") repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5" base_api = f"{server}/api/v1/repos/{repo}/actions"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try: try:
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:
@@ -50,30 +53,58 @@ jobs:
r for r in data.get("workflow_runs", []) r for r in data.get("workflow_runs", [])
if r.get("status") == "success" if r.get("status") == "success"
] ]
print(runs[0].get("commit_sha") or "") # Walk runs newest-first; pick the first one where deploy-playstore
# actually ran (conclusion=success), not just skipped.
for run in runs:
run_id = run.get("id")
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Deploy to Play Store" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
print("")
except Exception as e: except Exception as e:
print(f"API check failed: {e}", file=sys.stderr) print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print("") print("")
PYEOF PYEOF
) )
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then if [ -z "$LAST_DEPLOYED_SHA" ]; then
echo "HEAD $HEAD_SHA already successfully deployed — skipping" echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
echo "android=false" >> "$GITHUB_OUTPUT" echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT" echo "linux=false" >> "$GITHUB_OUTPUT"
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Diff from the last successfully deployed commit to catch all changes since # Diff from the last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit. Falls back to HEAD~1 when # that deploy, not just the most recent commit. Deploy all targets when the
# LAST_DEPLOYED_SHA is unknown or not in local history. # SHA is not in local history (shallow clone or very old deploy).
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA" echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \ CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD) || git show --name-only --format= HEAD)
else else
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|| git show --name-only --format= HEAD) echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi fi
echo "Changed files:" echo "Changed files:"
@@ -82,13 +113,25 @@ jobs:
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)' android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)' linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
echo "$CHANGED" | grep -qE "$android_re" \ if echo "$CHANGED" | grep -qE "$android_re"; then
&& echo "android=true" >> "$GITHUB_OUTPUT" \ echo "android=true" >> "$GITHUB_OUTPUT"
|| echo "android=false" >> "$GITHUB_OUTPUT" echo "Android deploy: TRIGGERED (android-relevant files changed)"
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "android=false" >> "$GITHUB_OUTPUT"
echo "Android deploy: SKIPPED (no android-relevant files changed)"
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
fi
echo "$CHANGED" | grep -qE "$linux_re" \ if echo "$CHANGED" | grep -qE "$linux_re"; then
&& echo "linux=true" >> "$GITHUB_OUTPUT" \ echo "linux=true" >> "$GITHUB_OUTPUT"
|| echo "linux=false" >> "$GITHUB_OUTPUT" echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "linux=false" >> "$GITHUB_OUTPUT"
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
fi
deploy-playstore: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
@@ -106,28 +149,23 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store - name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-android run: task publish-android
- name: Cleanup TLS credentials - name: Verify Play Store deployment
if: always() run: |
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid python3 -m venv /tmp/playstore-venv
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
deploy-apk: deploy-apk:
name: Build & Deploy APK to Server name: Build & Deploy APK to Server
@@ -145,31 +183,17 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server - name: Build & Deploy APK to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-apk run: task deploy-apk
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
build-linux: build-linux:
name: Build Linux Release name: Build Linux Release
@@ -187,29 +211,17 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server - name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-linux run: task deploy-linux
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
+2 -12
View File
@@ -58,28 +58,18 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab - name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env: env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task test-android-firebase run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
- name: Create issue on test failure - name: Create issue on test failure
if: failure() if: failure()
env: env:
-18
View File
@@ -1,18 +0,0 @@
name: Monitor Agent Loop
on:
schedule:
- cron: '0 */2 * * *' # every 2 hours
workflow_dispatch:
jobs:
monitor:
name: Check Agent Loop Health
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check agent loop heartbeat
run: python3 scripts/agent_loop.py monitor
+2 -11
View File
@@ -18,22 +18,13 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Run Renovate - name: Run Renovate
env: env:
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
run: task renovate run: task renovate
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+3 -17
View File
@@ -26,32 +26,18 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Update Website - name: Build & Update Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-website run: task publish-website
- name: Verify Website - name: Verify Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }} SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh run: scripts/website-verify.sh
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.44.0" "flutter": "3.44.1"
} }
+27 -32
View File
@@ -8,46 +8,41 @@ CLI tool `fgj` is available to query issues/PRs/actions.
## Issue Label Workflow ## Issue Label Workflow
We use issues, follow this label state machine: Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
- **State/ToPlan** — Issue needs a plan written by an agent before implementation | Label | Trigger | Outcome |
- **State/Planned** — Plan has been posted as a comment; awaiting human review |---|---|---|
- **State/Ready** — Issue is approved and ready for implementation | `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
- **State/InProgress** — Set while an agent (or human) is actively working | `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
- **State/Question** — Agent hit a blocker or needs clarification
Full lifecycle: **State machine:**
``` ```
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent) loop/plan loop/plan-in-progress → loop/plan-done
State/Planned → State/Ready (manual: human reviews the plan and approves) ↘ NeedSupervisor (on failure)
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
State/InProgress → closed (automated: after PR is merged and CI passes) loop/code → loop/code-in-progress loop/code-done
any state → State/Question (automated or manual: when blocked) NeedSupervisor (on failure)
``` ```
List open issues ready to pick up: **Rules:**
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
- Planning agents only post a comment — they do NOT write code or open PRs.
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
**Typical lifecycle for a new feature:**
```bash
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
``` ```
1. Create issue
Rules: 2. Add label loop/plan → agent writes plan as comment
3. Review plan, request changes or approve
- Never start implementation on an issue without `State/Ready` 4. Add label loop/code → agent implements + opens PR
- Planning agents only post a plan comment — they do NOT write code or open PRs 5. Review PR, merge
- After `State/Planned`, a human must review the plan and manually add `State/Ready` 6. Close issue
- When working via the agent loop: label transitions are set automatically ```
by `agent_loop.py` — do **not** set them yourself.
- When working manually: switch to `State/InProgress` as your **first action**:
```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
```
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
- When done and CI is green, close the issue:
```bash
fgj issue close <NUMBER>
```
## Code conventions ## Code conventions
+1 -1
View File
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
# Replace 1003 with the actual UID of dagger-svc # Replace 1003 with the actual UID of dagger-svc
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
Environment=XDG_RUNTIME_DIR=/run/user/1003 Environment=XDG_RUNTIME_DIR=/run/user/1003
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080 ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
Restart=always Restart=always
[Install] [Install]
+2
View File
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow ## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands. Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
+5
View File
@@ -216,3 +216,8 @@ test/
- **Settings** — list and remove accounts - **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change - **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 - **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
+16 -7
View File
@@ -218,7 +218,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS" - sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set" msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --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 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle: build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
@@ -247,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- 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 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk: deploy-apk:
desc: Build and deploy Android APK via Dagger desc: Build and deploy Android APK via Dagger
@@ -261,7 +261,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- 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 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website: publish-website:
desc: Build and publish website via Dagger desc: Build and publish website via Dagger
@@ -271,7 +271,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS" - sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set" msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- 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" - 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"
check-dagger: check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available) desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -294,11 +294,11 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2 echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2 echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90 sleep 90
else else
@@ -319,7 +319,16 @@ tasks:
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE" rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
} }
trap cleanup EXIT trap cleanup EXIT
until [ -s "$PORTFILE" ]; do sleep 0.05; done until [ -s "$PORTFILE" ]; do
sleep 0.05
if ! kill -0 "$RECV_PID" 2>/dev/null; then
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
retry_dagger dagger call --progress=plain -q -m ci --source=. check
RC=$?
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
exit $RC
fi
done
PORT=$(cat "$PORTFILE") PORT=$(cat "$PORTFILE")
retry_dagger env \ retry_dagger env \
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \ OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
+4 -2
View File
@@ -16,8 +16,10 @@ android {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
signingConfigs { signingConfigs {
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.3.21" apply false
} }
include(":app") include(":app")
+19 -27
View File
@@ -7,8 +7,8 @@ require (
github.com/Khan/genqlient v0.8.1 github.com/Khan/genqlient v0.8.1
github.com/dagger/otel-go v1.43.0 github.com/dagger/otel-go v1.43.0
github.com/vektah/gqlparser/v2 v2.5.33 github.com/vektah/gqlparser/v2 v2.5.33
go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/otel/trace v1.44.0
) )
require ( require (
@@ -21,33 +21,25 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect github.com/sosodev/duration v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/log v0.17.0 // indirect go.opentelemetry.io/otel/log v0.20.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.79.3 // indirect google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
+32
View File
@@ -43,36 +43,65 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= 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 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.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 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.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 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.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 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.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 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.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 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.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 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.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 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= 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 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= 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 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= 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 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= 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 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= 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 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= 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 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.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 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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
@@ -87,10 +116,13 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 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 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-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 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-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 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+37 -14
View File
@@ -181,7 +181,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container { func (m *Ci) toolchain() *dagger.Container {
return dag.Container(). return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6"). From("ghcr.io/cirruslabs/flutter:3.44.1").
WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
@@ -338,7 +338,12 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
return dag.Container(). return dag.Container().
From("alpine:3.21"). From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}). WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). // 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"}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}). WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519") WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
} }
@@ -480,11 +485,18 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel() defer cancel()
if _, err := m.CheckHygiene(ctx); err != nil { // Run cheap structural checks in parallel for faster fail detection.
return "Hygiene check failed", err var fastEg errgroup.Group
} fastEg.Go(func() error {
if _, err := m.CheckLayers(ctx); err != nil { _, err := m.CheckHygiene(ctx)
return "Layer check failed", err return err
})
fastEg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
if err := fastEg.Wait(); err != nil {
return "", err
} }
checkSetup := m.setup(m.checkSrc()) checkSetup := m.setup(m.checkSrc())
@@ -508,16 +520,19 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return coverage, err return coverage, err
} }
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
// sibling via context — which would surface as "context canceled" in dagger
// output and trigger spurious retries in check-dagger.
var testBackend, testIntegration string var testBackend, testIntegration string
eg, egCtx := errgroup.WithContext(ctx) var eg errgroup.Group
eg.Go(func() error { eg.Go(func() error {
var e error var e error
testBackend, e = m.TestBackend(egCtx) testBackend, e = m.TestBackend(ctx)
return e return e
}) })
eg.Go(func() error { eg.Go(func() error {
var e error var e error
testIntegration, e = m.TestIntegration(egCtx) testIntegration, e = m.TestIntegration(ctx)
return e return e
}) })
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
@@ -559,6 +574,8 @@ func (m *Ci) BuildWebsite(
knownHosts *dagger.Secret, knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
// +optional
commitHash string,
) *dagger.Directory { ) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost) buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
@@ -566,9 +583,13 @@ func (m *Ci) BuildWebsite(
Include: []string{"website/"}, Include: []string{"website/"},
}).WithDirectory("website/content/builds", buildHistory) }).WithDirectory("website/content/builds", buildHistory)
return m.Hugo(). hugo := m.Hugo().
WithDirectory("/src", websiteSource). WithDirectory("/src", websiteSource).
WithWorkdir("/src/website"). WithWorkdir("/src/website")
if commitHash != "" {
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
}
return hugo.
WithExec([]string{"hugo", "--minify"}). WithExec([]string{"hugo", "--minify"}).
Directory("public") Directory("public")
} }
@@ -580,8 +601,10 @@ func (m *Ci) PublishWebsite(
knownHosts *dagger.Secret, knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
// +optional
commitHash string,
) (string, error) { ) (string, error) {
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost) public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
return m.Deployer(sshKey, knownHosts). return m.Deployer(sshKey, knownHosts).
WithDirectory("/public", public). WithDirectory("/public", public).
@@ -879,7 +902,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid ` + "```" + `mermaid
flowchart TD flowchart TD
subgraph dagger ["Dagger · Check pipeline"] subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"] toolchain["toolchain\nflutter:3.44.1 + NDK + apt + precache"]
pubGet["pubGetLayer\nflutter pub get"] pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"] codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 36; const int dbSchemaVersion = 37;
@@ -15,6 +15,10 @@ abstract class EmailRepository {
int limit = 50, int limit = 50,
}); });
/// Returns threads from the INBOX mailbox of every account, sorted by latest
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
/// Returns all emails belonging to [threadId] in [mailboxPath]. /// Returns all emails belonging to [threadId] in [mailboxPath].
Stream<List<Email>> observeEmailsInThread( Stream<List<Email>> observeEmailsInThread(
String accountId, String accountId,
@@ -5,4 +5,8 @@ abstract class UserPreferencesRepository {
Future<void> updateMenuPosition(MenuPosition position); Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position); Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action); Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
Future<void> removeTrustedImageSender(String senderEmail);
} }
@@ -92,8 +92,9 @@ class ShareEncryptionService {
) { ) {
if (!s.startsWith(_pubKeyPrefix)) return null; if (!s.startsWith(_pubKeyPrefix)) return null;
try { try {
final data = final data = Uint8List.fromList(
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length))); base64.decode(s.substring(_pubKeyPrefix.length)),
);
if (data.length != _keyIdLen + _pubKeyLen) return null; if (data.length != _keyIdLen + _pubKeyLen) return null;
return ( return (
keyId: data.sublist(0, _keyIdLen), keyId: data.sublist(0, _keyIdLen),
+3 -2
View File
@@ -108,8 +108,9 @@ class SieveInterpreter {
} }
bool _globMatch(String value, String pattern) { bool _globMatch(String value, String pattern) {
final regexStr = final regexStr = RegExp.escape(
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); pattern,
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value); return RegExp('^$regexStr\$').hasMatch(value);
} }
+3 -9
View File
@@ -466,9 +466,7 @@ class _Scanner {
String readTaggedArg() { String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord(); if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException( throw SieveParseException('Expected tagged argument at position $_pos');
'Expected tagged argument at position $_pos',
);
} }
String? peekSizeUnit() { String? peekSizeUnit() {
@@ -480,9 +478,7 @@ class _Scanner {
String readDigits() { String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) { if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException( throw SieveParseException('Expected number at position $_pos');
'Expected number at position $_pos',
);
} }
final start = _pos; final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) { while (!isAtEnd && _isDigit(_src[_pos])) {
@@ -493,9 +489,7 @@ class _Scanner {
String readQuotedString() { String readQuotedString() {
if (_src[_pos] != '"') { if (_src[_pos] != '"') {
throw SieveParseException( throw SieveParseException('Expected " at position $_pos');
'Expected " at position $_pos',
);
} }
_pos++; // skip opening quote _pos++; // skip opening quote
final buf = StringBuffer(); final buf = StringBuffer();
+1 -4
View File
@@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"') .replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
.replaceAll("src='cid:$bareCid'", "src='$dataUri'") .replaceAll("src='cid:$bareCid'", "src='$dataUri'")
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"') .replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
.replaceAll( .replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
"src='cid:${bareCid.toLowerCase()}'",
"src='$dataUri'",
);
} }
return result; return result;
} }
+15
View File
@@ -307,6 +307,17 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId}; Set<Column> get primaryKey => {accountId, messageId};
} }
/// Senders for whom remote images are loaded automatically.
/// Per-device/per-user — not tied to any email account.
@DataClassName('ImageTrustedSenderRow')
class ImageTrustedSenders extends Table {
TextColumn get senderEmail => text()();
DateTimeColumn get addedAt => dateTime()();
@override
Set<Column> get primaryKey => {senderEmail};
}
/// App-wide user preferences, stored as a singleton row (id always 1). /// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow') @DataClassName('UserPreferencesRow')
class UserPreferences extends Table { class UserPreferences extends Table {
@@ -345,6 +356,7 @@ class UserPreferences extends Table {
LocalSieveApplied, LocalSieveApplied,
ShareKeys, ShareKeys,
UserPreferences, UserPreferences,
ImageTrustedSenders,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
@@ -611,6 +623,9 @@ class AppDatabase extends _$AppDatabase {
userPreferences.afterMailViewAction, userPreferences.afterMailViewAction,
); );
} }
if (from < 37) {
await m.createTable(imageTrustedSenders);
}
}, },
); );
} }
+11 -16
View File
@@ -9,8 +9,9 @@ class LocalSieveRepository {
final AppDatabase _db; final AppDatabase _db;
Future<List<SieveScript>> listScripts(String accountId) async { Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select(_db.localSieveScripts) final rows = await (_db.select(
..where((t) => t.accountId.equals(accountId))) _db.localSieveScripts,
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
return rows return rows
.map( .map(
@@ -26,10 +27,9 @@ class LocalSieveRepository {
Future<String> getScriptContent(String accountId, String blobId) async { Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId); final rowId = int.parse(blobId);
final row = await (_db.select(_db.localSieveScripts) final row = await (_db.select(
..where( _db.localSieveScripts,
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId'); if (row == null) throw Exception('Local script not found: $blobId');
return row.content; return row.content;
@@ -44,9 +44,7 @@ class LocalSieveRepository {
if (id != null) { if (id != null) {
final rowId = int.parse(id); final rowId = int.parse(id);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where( ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write( .write(
LocalSieveScriptsCompanion( LocalSieveScriptsCompanion(
name: Value(name), name: Value(name),
@@ -78,10 +76,9 @@ class LocalSieveRepository {
Future<void> deleteScript(String accountId, String scriptId) async { Future<void> deleteScript(String accountId, String scriptId) async {
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.delete(_db.localSieveScripts) await (_db.delete(
..where( _db.localSieveScripts,
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
))
.go(); .go();
} }
@@ -92,9 +89,7 @@ class LocalSieveRepository {
.write(const LocalSieveScriptsCompanion(isActive: Value(false))); .write(const LocalSieveScriptsCompanion(isActive: Value(false)));
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where( ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(const LocalSieveScriptsCompanion(isActive: Value(true))); .write(const LocalSieveScriptsCompanion(isActive: Value(true)));
}); });
} }
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl( DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
this._db, : _imapConnect = imapConnect;
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -124,10 +121,7 @@ class DraftRepositoryImpl implements DraftRepository {
} }
} }
Future<void> _syncWithServer( Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
imap.ImapClient client,
String accountId,
) async {
// Create/select the Drafts folder. // Create/select the Drafts folder.
try { try {
await client.createMailbox('Drafts'); await client.createMailbox('Drafts');
@@ -162,8 +156,9 @@ class DraftRepositoryImpl implements DraftRepository {
? uidList.first.toString() ? uidList.first.toString()
: null; : null;
if (uid != null) { if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))) await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
.write(DraftsCompanion(imapServerId: Value(uid))); DraftsCompanion(imapServerId: Value(uid)),
);
} }
} }
@@ -95,6 +95,26 @@ class EmailRepositoryImpl implements EmailRepository {
.map((rows) => rows.map(_threadRowToModel).toList()); .map((rows) => rows.map(_threadRowToModel).toList());
} }
@override
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
final query = _db.select(_db.threads).join([
innerJoin(
_db.mailboxes,
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
),
]);
query
..where(_db.mailboxes.role.equals('inbox'))
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
..limit(limit);
return query.watch().map(
(rows) => rows
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
.toList(),
);
}
model.EmailThread _threadRowToModel(ThreadRow row) { model.EmailThread _threadRowToModel(ThreadRow row) {
List<model.EmailAddress> parseAddresses(String json) { List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>; final list = jsonDecode(json) as List<dynamic>;
@@ -156,6 +176,7 @@ class EmailRepositoryImpl implements EmailRepository {
return; return;
} }
if (threadEmails.isEmpty) return;
final latest = threadEmails.last; final latest = threadEmails.last;
// Collect unique participants across the whole thread. // Collect unique participants across the whole thread.
@@ -237,7 +258,12 @@ class EmailRepositoryImpl implements EmailRepository {
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
final msg = fetch.messages.first; final msg = fetch.messages.firstOrNull;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final textBody = msg.decodeTextPlainPart(); final textBody = msg.decodeTextPlainPart();
final rawHtml = msg.decodeTextHtmlPart(); final rawHtml = msg.decodeTextHtmlPart();
final htmlBody = final htmlBody =
@@ -325,13 +351,7 @@ class EmailRepositoryImpl implements EmailRepository {
], ],
'fetchHTMLBodyValues': true, 'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true, 'fetchTextBodyValues': true,
'bodyProperties': [ 'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
'partId',
'type',
'name',
'size',
'subParts',
],
}, },
'0', '0',
], ],
@@ -1949,8 +1969,9 @@ class EmailRepositoryImpl implements EmailRepository {
.getSingleOrNull(); .getSingleOrNull();
final inboxPath = inboxMailbox?.path ?? 'INBOX'; final inboxPath = inboxMailbox?.path ?? 'INBOX';
final alreadyApplied = await (_db.select(_db.localSieveApplied) final alreadyApplied = await (_db.select(
..where((t) => t.accountId.equals(accountId))) _db.localSieveApplied,
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
@@ -2050,7 +2071,9 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(1)) ..limit(1))
.getSingleOrNull(); .getSingleOrNull();
if (destMailbox == null) { if (destMailbox == null) {
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}'); log(
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
);
return; return;
} }
destPath = destMailbox.path; destPath = destMailbox.path;
@@ -2808,11 +2831,13 @@ class EmailRepositoryImpl implements EmailRepository {
// Content-Transfer-Encoding) and getPart() can decode the part correctly. // Content-Transfer-Encoding) and getPart() can decode the part correctly.
// A partial BODY.PEEK[n] fetch omits those headers, causing // A partial BODY.PEEK[n] fetch omits those headers, causing
// decodeContentBinary() to return raw base64 instead of decoded bytes. // decodeContentBinary() to return raw base64 instead of decoded bytes.
final fetch = await client.uidFetchMessage( final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
emailRow.uid, final msg = fetch.messages.firstOrNull;
'BODY.PEEK[]', if (msg == null) {
); throw StateError(
final msg = fetch.messages.first; 'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final part = msg.getPart(attachment.fetchPartId) ?? msg; final part = msg.getPart(attachment.fetchPartId) ?? msg;
final bytes = part.decodeContentBinary(); final bytes = part.decodeContentBinary();
if (bytes == null) { if (bytes == null) {
@@ -2874,11 +2899,14 @@ class EmailRepositoryImpl implements EmailRepository {
); );
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage( final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
emailRow.uid, final msg = fetch.messages.firstOrNull;
'BODY.PEEK[]', if (msg == null) {
); throw StateError(
return fetch.messages.first.renderMessage(); 'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
return msg.renderMessage();
} finally { } finally {
await client.logout(); await client.logout();
} }
@@ -2955,6 +2983,20 @@ class EmailRepositoryImpl implements EmailRepository {
}) async { }) async {
if (query.length < 2) return []; if (query.length < 2) return [];
final pattern = '%${query.toLowerCase()}%'; final pattern = '%${query.toLowerCase()}%';
// Addresses we deliberately wrote to (sent folder) should appear before
// addresses that happened to email us (inbox/other folders).
final sentMailboxes = await (_db.select(_db.mailboxes)
..where((t) {
Expression<bool> cond = t.role.equals('sent');
if (accountId != null) {
cond = t.accountId.equals(accountId) & cond;
}
return cond;
}))
.get();
final sentPaths = {for (final m in sentMailboxes) m.path};
final rows = await (_db.select(_db.emails) final rows = await (_db.select(_db.emails)
..where((t) { ..where((t) {
Expression<bool> cond = const Constant(true); Expression<bool> cond = const Constant(true);
@@ -2969,11 +3011,22 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(100)) ..limit(100))
.get(); .get();
// Two passes: sent-folder rows first (prioritise recipients we chose),
// then other rows (senders who contacted us).
final sortedRows = [
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
];
final seen = <String>{}; final seen = <String>{};
final results = <model.EmailAddress>[]; final results = <model.EmailAddress>[];
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
for (final row in rows) { for (final row in sortedRows) {
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) { final isSent = sentPaths.contains(row.mailboxPath);
final fields = isSent
? [row.toAddresses, row.ccJson, row.fromJson]
: [row.fromJson, row.toAddresses, row.ccJson];
for (final jsonStr in fields) {
final list = jsonDecode(jsonStr) as List<dynamic>; final list = jsonDecode(jsonStr) as List<dynamic>;
for (final e in list) { for (final e in list) {
final map = e as Map<String, dynamic>; final map = e as Map<String, dynamic>;
@@ -3252,14 +3305,17 @@ class EmailRepositoryImpl implements EmailRepository {
await _db.customStatement('PRAGMA foreign_keys = OFF'); await _db.customStatement('PRAGMA foreign_keys = OFF');
try { try {
await _db.transaction(() async { await _db.transaction(() async {
await (_db.delete(_db.emails) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.emails,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete(_db.pendingChanges) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.pendingChanges,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete(_db.syncStates) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.syncStates,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
}); });
} finally { } finally {
@@ -82,8 +82,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
// Pre-load existing DB roles so we can preserve manually-set roles for // Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute. // folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(_db.mailboxes) final existingRows = await (_db.select(
..where((t) => t.accountId.equals(account.id))) _db.mailboxes,
)..where((t) => t.accountId.equals(account.id)))
.get(); .get();
final existingRoles = {for (final r in existingRows) r.id: r.role}; final existingRoles = {for (final r in existingRows) r.id: r.role};
@@ -320,8 +321,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete(_db.mailboxes) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.mailboxes,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@@ -367,7 +369,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role), role: Value(role),
), ),
); );
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id))) final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id)))
.getSingle(); .getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -419,8 +423,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role), role: Value(role),
), ),
); );
final row = await (_db.select(_db.mailboxes) final row = await (_db.select(
..where((t) => t.id.equals(dbId))) _db.mailboxes,
)..where((t) => t.id.equals(dbId)))
.getSingle(); .getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -24,8 +24,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
await _db.transaction(() async { await _db.transaction(() async {
// Remove existing entry for same query (deduplication). // Remove existing entry for same query (deduplication).
await (_db.delete(_db.searchHistoryEntries) await (_db.delete(
..where((t) => t.query.equals(trimmed))) _db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed)))
.go(); .go();
await _db.into(_db.searchHistoryEntries).insert( await _db.into(_db.searchHistoryEntries).insert(
@@ -43,8 +44,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
.get(); .get();
if (keepIds.isNotEmpty) { if (keepIds.isNotEmpty) {
await (_db.delete(_db.searchHistoryEntries) await (_db.delete(
..where((t) => t.id.isNotIn(keepIds))) _db.searchHistoryEntries,
)..where((t) => t.id.isNotIn(keepIds)))
.go(); .go();
} }
}); });
@@ -40,8 +40,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
await _pruneExpired(); await _pruneExpired();
final keyIdHex = _hex(keyId); final keyIdHex = _hex(keyId);
final row = await (_db.select(_db.shareKeys) final row = await (_db.select(
..where((t) => t.id.equals(keyIdHex))) _db.shareKeys,
)..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) return null; if (row == null) return null;
@@ -55,10 +56,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
} }
Future<void> _pruneExpired() async { Future<void> _pruneExpired() async {
await (_db.delete(_db.shareKeys) await (_db.delete(
..where( _db.shareKeys,
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()), )..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
))
.go(); .go();
} }
@@ -11,7 +11,9 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
@override @override
Stream<pref.UserPreferences> observePreferences() { Stream<pref.UserPreferences> observePreferences() {
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId))) return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull() .watchSingleOrNull()
.map(_rowToModel); .map(_rowToModel);
} }
@@ -48,6 +50,31 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
); );
} }
@override
Stream<List<String>> observeTrustedImageSenders() {
return (_db.select(_db.imageTrustedSenders)
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
.watch()
.map((rows) => rows.map((r) => r.senderEmail).toList());
}
@override
Future<void> addTrustedImageSender(String senderEmail) async {
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
ImageTrustedSendersCompanion(
senderEmail: Value(senderEmail.toLowerCase()),
addedAt: Value(DateTime.now()),
),
);
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
await (_db.delete(_db.imageTrustedSenders)
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
.go();
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) { static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences(); if (row == null) return const pref.UserPreferences();
return pref.UserPreferences( return pref.UserPreferences(
+51 -10
View File
@@ -101,8 +101,9 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider = final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
Provider<SearchHistoryRepository>((ref) { ref,
) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -135,8 +136,10 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider = final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
StreamProvider.autoDispose.family<bool, String>((ref, accountId) { ref,
accountId,
) {
return ref.watch(syncManagerProvider).watchSyncing(accountId); return ref.watch(syncManagerProvider).watchSyncing(accountId);
}); });
@@ -185,8 +188,9 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
final undoServiceProvider = final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new); UndoService.new,
);
/// Loads email header + body and marks the email as seen. /// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -207,10 +211,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
repo.getEmailBody(_emailId), repo.getEmailBody(_emailId),
]); ]);
unawaited(repo.setFlag(_emailId, seen: true)); unawaited(repo.setFlag(_emailId, seen: true));
final header = results[0] as Email?;
if (header != null) {
unawaited(_prefetchNextEmailBody(repo, header));
}
return (results[0] as Email?, results[1] as EmailBody); return (results[0] as Email?, results[1] as EmailBody);
} }
Future<void> _prefetchNextEmailBody(
EmailRepository repo,
Email header,
) async {
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return;
final threads =
await repo.observeThreads(header.accountId, header.mailboxPath).first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(_emailId),
);
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
final nextId = threads[currentIndex + 1].latestEmailId;
await repo.getEmailBody(nextId);
}
} }
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
return ref.watch(accountRepositoryProvider).observeAccounts();
});
final accountByIdProvider = final accountByIdProvider =
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) { StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref.watch(accountRepositoryProvider).observeAccounts().map( return ref.watch(accountRepositoryProvider).observeAccounts().map(
@@ -232,12 +264,21 @@ final accountConnectionStatusProvider =
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider = final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
Provider<UserPreferencesRepository>((ref) { ref,
) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider)); return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
}); });
final userPreferencesProvider = final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
StreamProvider.autoDispose<UserPreferences>((ref) { ref,
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences(); return ref.watch(userPreferencesRepositoryProvider).observePreferences();
}); });
final trustedImageSendersProvider =
StreamProvider.autoDispose<List<String>>((ref) {
return ref
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
+6 -1
View File
@@ -9,6 +9,7 @@ import 'package:sharedinbox/ui/screens/account_send_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart'; import 'package:sharedinbox/ui/screens/changelog_screen.dart';
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart'; import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'package:sharedinbox/ui/screens/edit_account_screen.dart'; import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
@@ -24,11 +25,15 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart';
final router = GoRouter( final router = GoRouter(
initialLocation: '/accounts', initialLocation: '/inbox',
routes: [ routes: [
ShellRoute( ShellRoute(
builder: (ctx, state, child) => UndoShell(child: child), builder: (ctx, state, child) => UndoShell(child: child),
routes: [ routes: [
GoRoute(
path: '/inbox',
builder: (ctx, state) => const CombinedInboxScreen(),
),
GoRoute( GoRoute(
path: '/accounts', path: '/accounts',
builder: (ctx, state) => const AccountListScreen(), builder: (ctx, state) => const AccountListScreen(),
+9 -7
View File
@@ -72,8 +72,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Future<void> _launchUrl(BuildContext context, Uri url) async { Future<void> _launchUrl(BuildContext context, Uri url) async {
try { try {
final launched = final launched = await launchUrl(
await launchUrl(url, mode: LaunchMode.externalApplication); url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -121,8 +123,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
); );
try { try {
final launched = final launched = await launchUrl(
await launchUrl(url, mode: LaunchMode.externalApplication); url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -176,9 +180,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited( unawaited(_launchUrl(context, Uri.parse(href)));
_launchUrl(context, Uri.parse(href)),
);
} }
}, },
); );
+1 -5
View File
@@ -219,11 +219,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
), ),
), ),
_Step.done => const Center( _Step.done => const Center(
child: Icon( child: Icon(Icons.check_circle, size: 64, color: Colors.green),
Icons.check_circle,
size: 64,
color: Colors.green,
),
), ),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
+2 -7
View File
@@ -158,10 +158,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
for (final account in selected) { for (final account in selected) {
final password = await repo.getPassword(account.id); final password = await repo.getPassword(account.id);
payloads.add( payloads.add(
AccountPayload( AccountPayload(accountJson: account.toJson(), password: password),
accountJson: account.toJson(),
password: password,
),
); );
} }
@@ -361,9 +358,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!))); unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text('Encrypted code copied to clipboard'),
'Encrypted code copied to clipboard',
),
), ),
); );
}, },
+3 -2
View File
@@ -12,8 +12,9 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')), appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: future: DefaultAssetBundle.of(
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'), context,
).loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
+393
View File
@@ -0,0 +1,393 @@
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);
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@override
ConsumerState<CombinedInboxScreen> createState() =>
_CombinedInboxScreenState();
}
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
return accountsAsync.when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
body: Center(child: Text('Error: $e')),
),
data: (accounts) {
if (accounts.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) context.go('/accounts');
});
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final accountNames = {
for (final a in accounts) a.id: a.displayName,
};
final showAccount = accounts.length > 1;
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _buildDrawer(context, accounts),
body: _buildBody(accountNames, showAccount),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
return AppBar(
title: const Text('Combined Inbox'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync all',
onPressed: () {
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
),
],
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text(
'sharedinbox.de',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
ListTile(
leading: const Icon(Icons.manage_accounts),
title: const Text('Accounts'),
onTap: () {
Navigator.pop(context);
context.go('/accounts');
},
),
ListTile(
leading: const Icon(Icons.person_add),
title: const Text('Add account'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/add'));
},
),
const Divider(),
for (final account in accounts)
ListTile(
leading: const Icon(Icons.inbox),
title: Text(account.displayName),
subtitle: Text(account.email),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/${account.id}/mailboxes'));
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/preferences'));
},
),
ListTile(
leading: const Icon(Icons.history),
title: const Text('Undo Log'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/undo-log'));
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/about'));
},
),
],
),
);
}
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
final emailRepo = ref.watch(emailRepositoryProvider);
return RefreshIndicator(
onRefresh: () async {
final accounts = ref.read(allAccountsProvider).value ?? [];
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeAllInboxThreads(limit: _limit),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
if (threads.isEmpty) {
return ListView(
children: const [
SizedBox(
height: 300,
child: Center(child: Text('No emails')),
),
],
);
}
return _buildThreadList(threads, accountNames, showAccount);
},
),
);
}
Widget _buildThreadList(
List<EmailThread> threads,
Map<String, String> accountNames,
bool showAccount,
) {
final hasMore = threads.length == _limit;
return ListView.builder(
itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
},
);
}
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,
) async {
final repo = ref.read(emailRepositoryProvider);
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(t.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
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)),
],
),
);
}
}
+3 -9
View File
@@ -194,9 +194,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Failed to open file: $e'), content: Text('Failed to open file: $e'),
@@ -213,9 +211,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Select an account first'), content: Text('Select an account first'),
@@ -255,9 +251,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Send failed: $e'), content: Text('Send failed: $e'),
+3 -3
View File
@@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget {
builder: (context, snapshot) => Text( builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode' 'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}', '${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(
color: Colors.grey[600], context,
), ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
+3 -2
View File
@@ -54,8 +54,9 @@ Future<Mailbox?> resolveMailboxByRole(
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
for (final m for (final m in mailboxes.where(
in mailboxes.where((m) => m.path != currentMailboxPath)) (m) => m.path != currentMailboxPath,
))
ListTile( ListTile(
leading: const Icon(Icons.folder_outlined), leading: const Icon(Icons.folder_outlined),
title: Text(m.name), title: Text(m.name),
+73 -101
View File
@@ -18,6 +18,7 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -72,9 +73,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited( unawaited(_replyWithRecipientDialog(context, header, body));
_replyWithRecipientDialog(context, header, body),
);
}, },
), ),
IconButton( IconButton(
@@ -126,22 +125,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem( const PopupMenuItem(value: 'forward', child: Text('Forward')),
value: 'forward', const PopupMenuItem(value: 'move', child: Text('Move to folder')),
child: Text('Forward'), const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
), const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
const PopupMenuItem(
value: 'move',
child: Text('Move to folder'),
),
const PopupMenuItem(
value: 'snooze',
child: Text('Snooze'),
),
const PopupMenuItem(
value: 'spam',
child: Text('Mark as spam'),
),
const PopupMenuItem( const PopupMenuItem(
value: 'mark_unread', value: 'mark_unread',
child: Text('Mark as unread'), child: Text('Mark as unread'),
@@ -155,10 +142,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure', value: 'structure',
child: Text('Show Mail Structure'), child: Text('Show Mail Structure'),
), ),
const PopupMenuItem( const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
value: 'rfc',
child: Text('Show Raw Email'),
),
], ],
onSelected: (value) async { onSelected: (value) async {
if (value == 'forward' && header != null) { if (value == 'forward' && header != null) {
@@ -187,19 +171,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body: detail.when( body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')), error: (e, _) => Center(child: Text('Error: $e')),
data: (d) => _buildBody(context, d.$1, d.$2), data: (d) {
final trusted =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
return _buildBody(context, d.$1, d.$2, trusted);
},
), ),
); );
} }
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) { Widget _buildBody(
BuildContext ctx,
Email? header,
EmailBody body,
List<String> trustedSenders,
) {
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final senderEmail = header?.from.isNotEmpty == true
? header!.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (header != null) ...[_buildHeader(ctx, header), const Divider()], if (header != null) ...[_buildHeader(ctx, header), const Divider()],
if (hasHtml) ...[ if (hasHtml) ...[
if (!_loadRemoteImages) if (!effectiveLoadImages)
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
@@ -207,13 +207,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.image_outlined, size: 18), icon: const Icon(Icons.image_outlined, size: 18),
label: const Text('Load remote images'), label: const Text('Load remote images'),
onPressed: () => setState(() => _loadRemoteImages = true), onPressed: () {
setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
);
}
},
),
),
);
}
},
), ),
), ),
), ),
SecureEmailWebView( SecureEmailWebView(
htmlBody: body.htmlBody!, htmlBody: body.htmlBody!,
loadRemoteImages: _loadRemoteImages, loadRemoteImages: effectiveLoadImages,
), ),
] else ] else
SelectableText( SelectableText(
@@ -264,8 +291,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.observeThreads(header.accountId, header.mailboxPath) .observeThreads(header.accountId, header.mailboxPath)
.first; .first;
final currentIndex = final currentIndex = threads.indexWhere(
threads.indexWhere((t) => t.emailIds.contains(widget.emailId)); (t) => t.emailIds.contains(widget.emailId),
);
if (currentIndex >= 0 && currentIndex + 1 < threads.length) { if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId; return threads[currentIndex + 1].latestEmailId;
} }
@@ -520,10 +548,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: { extra: {'prefillSubject': subject, 'prefillBody': quoted},
'prefillSubject': subject,
'prefillBody': quoted,
},
), ),
); );
} }
@@ -625,9 +650,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.fetchRawRfc822(widget.emailId); .fetchRawRfc822(widget.emailId);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to fetch raw email: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
return; return;
} }
@@ -741,47 +766,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => EmailHeadersDialog(headers: body.headers),
title: const Text('Mail Headers'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: body.headers.length,
itemBuilder: (ctx, i) {
final header = body.headers[i];
return Container(
color: i.isEven
? Theme.of(ctx).colorScheme.surfaceContainerHighest
: Theme.of(ctx).colorScheme.surface,
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
), ),
); );
} }
@@ -792,9 +777,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text( content: Text('Structure not available. Try re-syncing the email.'),
'Structure not available. Try re-syncing the email.',
),
), ),
); );
return; return;
@@ -806,12 +789,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => Dialog.fullscreen(
title: const Text('Mail Structure'), child: Scaffold(
content: SizedBox( appBar: AppBar(
width: double.maxFinite, title: const Text('Mail Structure'),
child: ListView.builder( leading: const CloseButton(),
shrinkWrap: true, ),
body: ListView.builder(
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final row = rows[i]; final row = rows[i];
@@ -840,12 +824,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}, },
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
), ),
), ),
); );
@@ -903,14 +881,8 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
SegmentedButton<_Placement>( SegmentedButton<_Placement>(
showSelectedIcon: false, showSelectedIcon: false,
segments: const [ segments: const [
ButtonSegment( ButtonSegment(value: _Placement.to, label: Text('To')),
value: _Placement.to, ButtonSegment(value: _Placement.cc, label: Text('Cc')),
label: Text('To'),
),
ButtonSegment(
value: _Placement.cc,
label: Text('Cc'),
),
ButtonSegment( ButtonSegment(
value: _Placement.skip, value: _Placement.skip,
label: Text('Skip'), label: Text('Skip'),
+3 -8
View File
@@ -381,11 +381,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
return MaterialBanner( return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text( content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon( leading: Icon(
Icons.sync_problem, Icons.sync_problem,
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
@@ -399,9 +395,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: const Text('Retry'), child: const Text('Retry'),
), ),
TextButton( TextButton(
onPressed: () => context.push( onPressed: () =>
'/accounts/${widget.accountId}/sync-log', context.push('/accounts/${widget.accountId}/sync-log'),
),
child: const Text('View log'), child: const Text('View log'),
), ),
TextButton( TextButton(
+3 -2
View File
@@ -10,8 +10,9 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
FutureProvider.autoDispose<List<String>>((ref) async { ref,
) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches(); return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
}); });
+1 -3
View File
@@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
), ),
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+52 -6
View File
@@ -113,6 +113,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final trustedSenders =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
final senderEmail = widget.email.from.isNotEmpty
? widget.email.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@@ -147,13 +155,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
], ],
), ),
), ),
if (_expanded) _buildExpandedBody(), if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
], ],
), ),
); );
} }
Widget _buildExpandedBody() { Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column( child: Column(
@@ -163,6 +171,17 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
FutureBuilder<EmailBody>( FutureBuilder<EmailBody>(
future: _bodyFuture, future: _bodyFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Failed to load email: ${snapshot.error}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
);
}
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center( return const Center(
child: Padding( child: Padding(
@@ -173,21 +192,48 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
} }
final body = snapshot.data!; final body = snapshot.data!;
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (hasHtml) ...[ if (hasHtml) ...[
if (!_loadRemoteImages) if (!effectiveLoadImages)
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.image_outlined, size: 16), icon: const Icon(Icons.image_outlined, size: 16),
label: const Text('Load remote images'), label: const Text('Load remote images'),
onPressed: () => onPressed: () {
setState(() => _loadRemoteImages = true), setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
);
}
},
),
),
);
}
},
), ),
SecureEmailWebView( SecureEmailWebView(
htmlBody: body.htmlBody!, htmlBody: body.htmlBody!,
loadRemoteImages: _loadRemoteImages, loadRemoteImages: effectiveLoadImages,
), ),
] else ] else
SelectableText( SelectableText(
+1 -3
View File
@@ -84,9 +84,7 @@ class _UndoActionTile extends ConsumerWidget {
.read(undoServiceProvider.notifier) .read(undoServiceProvider.notifier)
.undo(actionId: action.id); .undo(actionId: action.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Action undone.'), content: Text('Action undone.'),
+43 -9
View File
@@ -12,6 +12,7 @@ class UserPreferencesScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider); final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Preferences')), appBar: AppBar(title: const Text('Preferences')),
@@ -90,9 +91,7 @@ class UserPreferencesScreen extends ConsumerWidget {
), ),
RadioListTile<MenuPosition>( RadioListTile<MenuPosition>(
title: Text('Top'), title: Text('Top'),
subtitle: Text( subtitle: Text('Show the back button in the top bar.'),
'Show the back button in the top bar.',
),
value: MenuPosition.top, value: MenuPosition.top,
), ),
], ],
@@ -122,21 +121,56 @@ class UserPreferencesScreen extends ConsumerWidget {
children: [ children: [
RadioListTile<AfterMailViewAction>( RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'), title: Text('Next message (default)'),
subtitle: Text( subtitle: Text('Show the next message in the mailbox.'),
'Show the next message in the mailbox.',
),
value: AfterMailViewAction.nextMessage, value: AfterMailViewAction.nextMessage,
), ),
RadioListTile<AfterMailViewAction>( RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'), title: Text('Return to mailbox'),
subtitle: Text( subtitle: Text('Return to the message list.'),
'Return to the message list.',
),
value: AfterMailViewAction.showMailbox, value: AfterMailViewAction.showMailbox,
), ),
], ],
), ),
), ),
const Divider(),
ListTile(
title: Text(
'Trusted image senders',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Remote images are loaded automatically for these senders.',
),
),
...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),
);
},
),
),
],
),
], ],
), ),
), ),
+3 -2
View File
@@ -26,8 +26,9 @@ String buildAboutMarkdown({
final osName = _capitalize(Platform.operatingSystem); final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark; final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString(); final locale = Localizations.localeOf(context).toString();
final textScale = final textScale = MediaQuery.of(
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1); context,
).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n' ? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
+258
View File
@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
/// Full-screen dialog for browsing email headers, organised into groups.
class EmailHeadersDialog extends StatelessWidget {
const EmailHeadersDialog({super.key, required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: const Text('Mail Headers'),
leading: const CloseButton(),
),
body: _HeadersBody(headers: headers),
),
);
}
}
class _HeadersBody extends StatelessWidget {
const _HeadersBody({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final receivedHeaders = <EmailHeader>[];
final listHeaders = <EmailHeader>[];
final arcHeaders = <EmailHeader>[];
final otherHeaders = <EmailHeader>[];
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
final xByPrefix = <String, List<EmailHeader>>{};
for (final h in headers) {
final lower = h.name.toLowerCase();
if (lower == 'received') {
receivedHeaders.add(h);
continue;
}
if (lower.startsWith('list-')) {
listHeaders.add(h);
continue;
}
if (lower.startsWith('arc-')) {
arcHeaders.add(h);
continue;
}
if (lower.startsWith('x-')) {
final parts = h.name.split('-');
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
xByPrefix.putIfAbsent(prefix, () => []).add(h);
continue;
}
otherHeaders.add(h);
}
final sections = <Widget>[];
if (otherHeaders.isNotEmpty) {
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
}
if (listHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'List- Headers', headers: listHeaders),
);
}
if (receivedHeaders.isNotEmpty) {
sections.add(_ReceivedSection(headers: receivedHeaders));
}
if (arcHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
);
}
// X- headers at bottom, each prefix in its own collapsible group.
final sortedPrefixes = xByPrefix.keys.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
for (final prefix in sortedPrefixes) {
sections.add(
_HeadersSection(
title: '$prefix Headers',
headers: xByPrefix[prefix]!,
),
);
}
return ListView(children: sections);
}
}
class _HeadersSection extends StatelessWidget {
const _HeadersSection({required this.title, required this.headers});
final String title;
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return ExpansionTile(
title: Text('$title (${headers.length})'),
children: [
for (var i = 0; i < headers.length; i++)
_HeaderRow(header: headers[i], index: i),
],
);
}
}
/// Received headers section — collapsed by default; shows inter-hop delays.
class _ReceivedSection extends StatelessWidget {
const _ReceivedSection({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final entries = _buildEntries(headers);
return ExpansionTile(
title: Text('Received (${headers.length})'),
children: [
for (var i = 0; i < entries.length; i++) ...[
_HeaderRow(header: entries[i].header, index: i),
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
],
],
);
}
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
final timestamps =
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
return [
for (var i = 0; i < headers.length; i++)
_ReceivedEntry(
header: headers[i],
delay: _computeDelay(timestamps, i),
),
];
}
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
if (i >= timestamps.length - 1) return null;
final current = timestamps[i];
final next = timestamps[i + 1];
if (current == null || next == null) return null;
final d = current.difference(next);
return d.isNegative ? Duration.zero : d;
}
}
class _ReceivedEntry {
const _ReceivedEntry({required this.header, this.delay});
final EmailHeader header;
final Duration? delay;
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.header, required this.index});
final EmailHeader header;
final int index;
@override
Widget build(BuildContext context) {
final bg = index.isEven
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
}
}
class _DelayRow extends StatelessWidget {
const _DelayRow({required this.delay});
final Duration delay;
@override
Widget build(BuildContext context) {
final color = _delayColor(delay);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Icon(Icons.arrow_downward, size: 14, color: color),
const SizedBox(width: 4),
Text(
_formatDuration(delay),
style: TextStyle(
fontSize: 12,
color: color,
fontWeight:
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
),
),
],
),
);
}
}
/// Parses the RFC 2822 timestamp from a Received header value.
///
/// Received headers end with `; date`, e.g.:
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
DateTime? _parseReceivedTimestamp(String value) {
final semiIndex = value.lastIndexOf(';');
if (semiIndex < 0) return null;
var s = value.substring(semiIndex + 1).trim();
// Strip parenthesised comments like (UTC).
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
// Strip leading day-of-week abbreviation like "Mon, ".
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
// Collapse runs of whitespace.
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
for (final fmt in [
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
]) {
try {
return fmt.parse(s);
} catch (_) {}
}
return null;
}
String _formatDuration(Duration d) {
if (d.inSeconds < 60) return '${d.inSeconds}s';
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
}
Color _delayColor(Duration d) {
if (d.inSeconds < 30) return Colors.green;
if (d.inSeconds < 300) return Colors.orange;
return Colors.red;
}
+17 -11
View File
@@ -111,12 +111,16 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
Future<void> _measureHeight(String _) async { Future<void> _measureHeight(String _) async {
final result = await _controller!.runJavaScriptReturningResult( try {
'document.documentElement.scrollHeight', final result = await _controller!.runJavaScriptReturningResult(
); 'document.documentElement.scrollHeight',
final h = double.tryParse(result.toString()); );
if (h != null && h > 0 && mounted) { final h = double.tryParse(result.toString());
setState(() => _height = h); if (h != null && h > 0 && mounted) {
setState(() => _height = h);
}
} catch (_) {
// WebView not ready yet; height stays at default
} }
} }
@@ -187,12 +191,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final launched = final launched = await launchUrl(
await launchUrl(uri, mode: LaunchMode.externalApplication); uri,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) { if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Could not open: $url')), context,
); ).showSnackBar(SnackBar(content: Text('Could not open: $url')));
} }
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@ dependencies:
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
# Date formatting # Date formatting
intl: any intl: ^0.20.2
# File picking (compose attachments) and opening downloaded attachments # File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4 file_picker: ^12.0.0-beta.4
+23
View File
@@ -11,6 +11,29 @@
{ {
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"], "matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"] "addLabels": ["automerge"]
},
{
"matchManagers": ["gomod"],
"matchFileNames": ["ci/**"],
"enabled": false
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^\\.forgejo/Dockerfile$"],
"matchStrings": ["DAGGER_VERSION=(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "dagger/dagger",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"fileMatch": ["^DAGGER\\.md$"],
"matchStrings": ["github:dagger/nix/v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)#dagger"],
"depNameTemplate": "dagger/dagger",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
} }
] ]
} }
File diff suppressed because it is too large Load Diff
+2
View File
@@ -42,6 +42,7 @@ const _excluded = {
'lib/ui/screens/add_account_screen.dart', 'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/address_emails_screen.dart', 'lib/ui/screens/address_emails_screen.dart',
'lib/ui/screens/changelog_screen.dart', 'lib/ui/screens/changelog_screen.dart',
'lib/ui/screens/combined_inbox_screen.dart',
'lib/ui/screens/compose_screen.dart', 'lib/ui/screens/compose_screen.dart',
'lib/ui/screens/crash_screen.dart', 'lib/ui/screens/crash_screen.dart',
'lib/ui/screens/edit_account_screen.dart', 'lib/ui/screens/edit_account_screen.dart',
@@ -62,6 +63,7 @@ const _excluded = {
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart', 'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart', 'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_headers_dialog.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart', 'lib/core/sync/background_sync.dart',
+65 -94
View File
@@ -1,108 +1,79 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
set -euo pipefail set -euo pipefail
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: DAGGER_STUNNEL_URL must be set." echo "Error: SOPS_AGE_KEY must be set."
exit 1 exit 1
fi fi
# Parse host and port (e.g., example.com:8774 or just example.com) echo "Decrypting secrets with SOPS..."
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1) export SOPS_AGE_KEY="$SOPS_AGE_KEY"
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2) SECRETS_JSON=$(mktemp)
if [ "$host" == "$port" ]; then trap "rm -f $SECRETS_JSON" EXIT
port="8774"
fi
MAX_PROBE_ATTEMPTS=5 sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
PROBE_DELAY=30
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active server on $host:$port"
break
fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
if ! docker info >/dev/null 2>&1; then
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
echo "or that Docker is running locally (check: sudo systemctl start docker)."
exit 1
fi
echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0
fi
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
sleep $PROBE_DELAY
done
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS) DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
echo "Trying plain TCP Dagger connection at tcp://$host:$port..." DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \ # Export all CI secrets to the GitHub Actions environment so subsequent steps
timeout 8 dagger version >/dev/null 2>&1; then # can use them without referencing Forgejo secrets directly.
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed." export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
if [ -n "${GITHUB_ENV:-}" ]; then if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" # Use heredoc syntax for multiline-safe export.
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" # Avoid adding a second trailing newline for values that already end with one
else # (e.g. SSH private keys), which can corrupt PEM parsing.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" {
export _DAGGER_RUNNER_HOST="tcp://$host:$port" printf '%s<<__EOF__\n' "$name"
echo "Dagger configured at tcp://$host:$port (plain TCP)" printf '%s' "$value"
[ "${value%$'\n'}" = "$value" ] && printf '\n'
printf '__EOF__\n'
} >> "$GITHUB_ENV"
fi fi
exit 0 printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
}
export_secret "SSH_PRIVATE_KEY"
export_secret "SSH_KNOWN_HOSTS"
export_secret "SSH_USER"
export_secret "SSH_HOST"
export_secret "WEBSITE_SSH_HOST"
export_secret "PLAY_STORE_CONFIG_JSON"
export_secret "ANDROID_KEYSTORE_BASE64"
export_secret "ANDROID_KEYSTORE_PASSWORD"
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
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
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
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"
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
fi fi
echo "Plain TCP connection not available; trying TLS stunnel..."
# 2b. Setup TLS credentials (passed as env vars from secrets) # Verify the connection
mkdir -p /tmp/dagger-tls echo "Verifying connection to Dagger engine via SSH tunnel..."
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt # Use a simple command that doesn't require complex GraphQL operations.
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
chmod 600 /tmp/dagger-tls/client.key # Debug
ps aux | grep ssh
# 3. Configure and start stunnel
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
cat << EOF > "$STUNNEL_CONF"
client = yes
foreground = yes
pid = /tmp/stunnel.pid
debug = warning
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
socket = r:SO_KEEPALIVE=1
socket = r:TCP_KEEPIDLE=10
socket = r:TCP_KEEPINTVL=5
socket = r:TCP_KEEPCNT=3
[dagger]
accept = 127.0.0.1:1774
connect = $host:$port
CAfile = /tmp/dagger-tls/ca.crt
cert = /tmp/dagger-tls/client.crt
key = /tmp/dagger-tls/client.key
verifyChain = yes
EOF
# Start stunnel in the background
stunnel "$STUNNEL_CONF" &
TUNNEL_PID=$!
# Give it a moment to establish
sleep 2
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
echo "Error: stunnel failed to start"
exit 1 exit 1
fi fi
echo "Dagger connection verified successfully."
# 4. Export environment for subsequent CI steps
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
echo "Tunnel established. Dagger is configured to use the remote engine."
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
fi
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Tests for verify_playstore_deploy.py."""
import os
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent))
import verify_playstore_deploy
def _make_session(version_code, track="internal"):
"""Return a mock AuthorizedSession with the given version code on the track."""
session = MagicMock()
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": "edit-99"}
session.post.return_value = edit_resp
track_resp = MagicMock()
track_resp.json.return_value = {
"releases": [{"versionCodes": [str(version_code)], "status": "completed"}]
}
session.get.return_value = track_resp
session.delete.return_value = MagicMock()
return session
class TestMissingEnv(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
verify_playstore_deploy.main()
self.assertEqual(ctx.exception.code, 1)
class TestRecentDeploy(unittest.TestCase):
def _run(self, version_code):
session = _make_session(version_code)
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
verify_playstore_deploy.main()
def test_recent_version_code_passes(self):
# Version code is Unix timestamp — a very recent one should pass.
recent_vc = int(time.time()) - 60 # 1 minute ago
self._run(recent_vc)
def test_old_version_code_fails(self):
old_vc = int(time.time()) - 7200 # 2 hours ago
with self.assertRaises(SystemExit) as ctx:
self._run(old_vc)
self.assertEqual(ctx.exception.code, 1)
class TestEmptyTrack(unittest.TestCase):
def _run_empty(self, releases):
session = MagicMock()
session.post.return_value = MagicMock(**{"json.return_value": {"id": "edit-1"}})
session.get.return_value = MagicMock(**{"json.return_value": {"releases": releases}})
session.delete.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
verify_playstore_deploy.main()
def test_no_releases_exits(self):
with self.assertRaises(SystemExit) as ctx:
self._run_empty([])
self.assertEqual(ctx.exception.code, 1)
def test_release_with_no_version_codes_exits(self):
with self.assertRaises(SystemExit) as ctx:
self._run_empty([{"status": "completed", "versionCodes": []}])
self.assertEqual(ctx.exception.code, 1)
if __name__ == "__main__":
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Verify that the Android app was recently published to the Play Store internal track.
The publish-android pipeline sets versionCode = int(time.Now().Unix()), so a
freshly deployed release always has a version code close to the current Unix
timestamp. This script queries the internal track and fails if the latest
version code is older than _MAX_DEPLOY_AGE_SECONDS, which would mean the
deployment silently did not land.
"""
import json
import os
import sys
import time
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
TRACK = "internal"
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
# Allow up to one hour for the build + upload to complete.
_MAX_DEPLOY_AGE_SECONDS = 3600
def main():
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
if not config_json:
print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr)
sys.exit(1)
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
# Open a read-only edit to query the current track state.
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
try:
track_resp = session.get(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
timeout=30,
)
track_resp.raise_for_status()
track_data = track_resp.json()
finally:
# Discard the edit — we made no changes.
try:
session.delete(f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}", timeout=30)
except Exception:
pass
releases = track_data.get("releases", [])
if not releases:
print(
f"ERROR: No releases found on {TRACK} track — deploy may have failed silently",
file=sys.stderr,
)
sys.exit(1)
all_version_codes = [
int(vc)
for release in releases
for vc in release.get("versionCodes", [])
]
if not all_version_codes:
print("ERROR: Latest release has no version codes", file=sys.stderr)
sys.exit(1)
latest_vc = max(all_version_codes)
now = int(time.time())
# versionCode is set to Unix timestamp by PublishAndroid in ci/main.go.
age_seconds = now - latest_vc
print(f"Latest version code on {TRACK} track: {latest_vc}")
print(f"Current time: {now} — version code age: {age_seconds}s")
if age_seconds > _MAX_DEPLOY_AGE_SECONDS:
print(
f"::error::Latest version code {latest_vc} is {age_seconds}s old "
f"(limit: {_MAX_DEPLOY_AGE_SECONDS}s). The deploy may have failed silently.",
file=sys.stderr,
)
sys.exit(1)
print(f"OK: version {latest_vc} verified on {TRACK} track ({age_seconds}s old)")
if __name__ == "__main__":
main()
+33
View File
File diff suppressed because one or more lines are too long
+53 -49
View File
@@ -20,63 +20,67 @@ Future<imap.ImapClient> _fakeImapConnect(
throw const SocketException('fake — no real IMAP server in tests'); throw const SocketException('fake — no real IMAP server in tests');
void main() { void main() {
test('AccountSyncManager schedules IMAP sync for multiple accounts', test(
() async { 'AccountSyncManager schedules IMAP sync for multiple accounts',
final accounts = _FakeAccounts('pw'); () async {
final mailboxes = _FakeMailboxes(); final accounts = _FakeAccounts('pw');
final emails = _FakeEmails(); final mailboxes = _FakeMailboxes();
final logs = _FakeLogs(); final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager( final manager = AccountSyncManager(
accounts, accounts,
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
imapConnect: _fakeImapConnect, imapConnect: _fakeImapConnect,
); );
final a1 = _account('1'); final a1 = _account('1');
final a2 = _account('2'); final a2 = _account('2');
manager.start(); manager.start();
accounts.push([a1, a2]); accounts.push([a1, a2]);
// Allow some time for listeners to fire. // Allow some time for listeners to fire.
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose(); manager.dispose();
}); },
);
test('AccountSyncManager schedules JMAP sync for multiple accounts', test(
() async { 'AccountSyncManager schedules JMAP sync for multiple accounts',
final accounts = _FakeAccounts('pw'); () async {
final mailboxes = _FakeMailboxes(); final accounts = _FakeAccounts('pw');
final emails = _FakeEmails(); final mailboxes = _FakeMailboxes();
final logs = _FakeLogs(); final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager( final manager = AccountSyncManager(
accounts, accounts,
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
); );
final a1 = _jmapAccount('1'); final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2'); final a2 = _jmapAccount('2');
manager.start(); manager.start();
accounts.push([a1, a2]); accounts.push([a1, a2]);
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose(); manager.dispose();
}); },
);
} }
Account _account(String id) => Account( Account _account(String id) => Account(
@@ -171,11 +175,7 @@ class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{}; final syncCounts = <String, int>{};
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
@@ -186,6 +186,10 @@ class _FakeEmails implements EmailRepository {
}) => }) =>
Stream.value([]); Stream.value([]);
@override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
+51 -49
View File
@@ -566,59 +566,61 @@ void main() {
expect(pending.first.changeType, 'delete'); expect(pending.first.changeType, 'delete');
}); });
test('downloadAttachment fetches binary attachment bytes from IMAP', test(
() async { 'downloadAttachment fetches binary attachment bytes from IMAP',
final attachmentBytes = Uint8List.fromList( () async {
List.generate(32, (i) => i + 1), final attachmentBytes = Uint8List.fromList(
); List.generate(32, (i) => i + 1),
const attachmentName = 'hello.bin';
const attachmentMime = 'application/octet-stream';
// Build a multipart email with a binary attachment and append it.
final client = await _imapConnect(
host: imapHost,
port: imapPort,
user: userEmail,
pass: userPass,
);
try {
final builder = MessageBuilder()
..from = [MailAddress('Alice', userEmail)]
..to = [MailAddress('Alice', userEmail)]
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
..text = 'See attachment.';
builder.addBinary(
attachmentBytes,
MediaType.fromText(attachmentMime),
filename: attachmentName,
); );
await client.appendMessage( const attachmentName = 'hello.bin';
builder.buildMimeMessage(), const attachmentMime = 'application/octet-stream';
targetMailboxPath: 'INBOX',
// Build a multipart email with a binary attachment and append it.
final client = await _imapConnect(
host: imapHost,
port: imapPort,
user: userEmail,
pass: userPass,
); );
} finally { try {
await client.logout(); final builder = MessageBuilder()
} ..from = [MailAddress('Alice', userEmail)]
..to = [MailAddress('Alice', userEmail)]
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
..text = 'See attachment.';
builder.addBinary(
attachmentBytes,
MediaType.fromText(attachmentMime),
filename: attachmentName,
);
await client.appendMessage(
builder.buildMimeMessage(),
targetMailboxPath: 'INBOX',
);
} finally {
await client.logout();
}
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first; final emails = await r.emails.observeEmails('test', 'INBOX').first;
expect(emails, hasLength(1)); expect(emails, hasLength(1));
expect(emails.first.hasAttachment, isTrue); expect(emails.first.hasAttachment, isTrue);
final body = await r.emails.getEmailBody(emails.first.id); final body = await r.emails.getEmailBody(emails.first.id);
expect(body.attachments, hasLength(1)); expect(body.attachments, hasLength(1));
expect(body.attachments.first.filename, attachmentName); expect(body.attachments.first.filename, attachmentName);
expect(body.attachments.first.contentType, attachmentMime); expect(body.attachments.first.contentType, attachmentMime);
expect(body.attachments.first.fetchPartId, isNotEmpty); expect(body.attachments.first.fetchPartId, isNotEmpty);
final path = await r.emails.downloadAttachment( final path = await r.emails.downloadAttachment(
emails.first.id, emails.first.id,
body.attachments.first, body.attachments.first,
); );
final downloaded = await File(path).readAsBytes(); final downloaded = await File(path).readAsBytes();
expect(downloaded, equals(attachmentBytes)); expect(downloaded, equals(attachmentBytes));
}); },
);
} }
@@ -73,13 +73,15 @@ abstract class AccountRepositoryContract {
expect(await repo.getPassword(_a.id), 'new'); expect(await repo.getPassword(_a.id), 'new');
}); });
test('removeAccount makes account disappear from observeAccounts', test(
() async { 'removeAccount makes account disappear from observeAccounts',
final repo = makeRepo(); () async {
await repo.addAccount(_a, 'pw'); final repo = makeRepo();
await repo.removeAccount(_a.id); await repo.addAccount(_a, 'pw');
expect(await repo.observeAccounts().first, isEmpty); await repo.removeAccount(_a.id);
}); expect(await repo.observeAccounts().first, isEmpty);
},
);
test('getAccount returns null after removeAccount', () async { test('getAccount returns null after removeAccount', () async {
final repo = makeRepo(); final repo = makeRepo();
+27 -27
View File
@@ -37,44 +37,41 @@ void main() {
// MissingPluginException (channel unavailable on the device), the IMAP sync // MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff. // loop must stop permanently instead of retrying indefinitely with backoff.
test( test(
'MissingPluginException from secure storage stops IMAP sync loop permanently', 'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async { () async {
final syncLog = FakeSyncLogRepository(); final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager( final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(), _AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(), FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(), FakeEmailRepository(),
syncLog: syncLog, syncLog: syncLog,
); );
m.start(); m.start();
// Allow the first sync cycle to run and fail. // Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1)); expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse); expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently. // Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1'); m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries. // Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry. // After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1)); expect(syncLog.logs, hasLength(1));
m.dispose(); m.dispose();
}); },
);
} }
class FakeEmailRepository implements EmailRepository { class FakeEmailRepository implements EmailRepository {
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
@@ -84,6 +81,9 @@ class FakeEmailRepository implements EmailRepository {
}) => }) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
@override @override
@@ -287,6 +287,17 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(), returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>); ) as _i5.Stream<List<_i3.EmailThread>>);
@override
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
(super.noSuchMethod(
Invocation.method(
#observeAllInboxThreads,
[],
{#limit: limit},
),
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>);
@override @override
_i5.Stream<List<_i3.Email>> observeEmailsInThread( _i5.Stream<List<_i3.Email>> observeEmailsInThread(
String? accountId, String? accountId,
+9 -8
View File
@@ -9,12 +9,13 @@ void main() {
// startup, throwing PlatformException(channel-error, ...). // startup, throwing PlatformException(channel-error, ...).
// registerBackgroundSync() must absorb the failure and let the app continue. // registerBackgroundSync() must absorb the failure and let the app continue.
test( test(
'registerBackgroundSync completes without throwing when plugin is unavailable', 'registerBackgroundSync completes without throwing when plugin is unavailable',
() async { () async {
// In the unit-test environment the native WorkManager plugin is not // In the unit-test environment the native WorkManager plugin is not
// registered, so Workmanager().initialize() throws a PlatformException or // registered, so Workmanager().initialize() throws a PlatformException or
// MissingPluginException. The fix catches it. This test fails before the // MissingPluginException. The fix catches it. This test fails before the
// fix (exception propagates) and passes after it (exception is swallowed). // fix (exception propagates) and passes after it (exception is swallowed).
await expectLater(registerBackgroundSync(), completes); await expectLater(registerBackgroundSync(), completes);
}); },
);
} }
+3 -2
View File
@@ -86,8 +86,9 @@ void main() {
final result = injectInlineImages(html, msg); final result = injectInlineImages(html, msg);
// Extract base64 payload from the data URI. // Extract base64 payload from the data URI.
final match = final match = RegExp(
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result); r'data:image/png;base64,([A-Za-z0-9+/=]+)',
).firstMatch(result);
expect(match, isNotNull); expect(match, isNotNull);
final decoded = base64.decode(match!.group(1)!); final decoded = base64.decode(match!.group(1)!);
expect(decoded.length, greaterThan(0)); expect(decoded.length, greaterThan(0));
+4 -17
View File
@@ -44,10 +44,7 @@ abstract class EmailRepositoryContract {
void run() { void run() {
test('observeEmails starts empty', () async { test('observeEmails starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty);
await repo.observeEmails(_account.id, 'INBOX').first,
isEmpty,
);
}); });
test('observeEmails emits inserted email', () async { test('observeEmails emits inserted email', () async {
@@ -61,10 +58,7 @@ abstract class EmailRepositoryContract {
test('observeEmails only returns emails for the given mailbox', () async { test('observeEmails only returns emails for the given mailbox', () async {
final repo = await makeRepo(); final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX'); await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
expect( expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty);
await repo.observeEmails(_account.id, 'Sent').first,
isEmpty,
);
}); });
test('observeEmails orders by receivedAt descending', () async { test('observeEmails orders by receivedAt descending', () async {
@@ -116,11 +110,7 @@ abstract class EmailRepositoryContract {
test('setFlag flagged updates isFlagged', () async { test('setFlag flagged updates isFlagged', () async {
final repo = await makeRepo(); final repo = await makeRepo();
await insertEmail( await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX');
repo,
id: 'er-acc:11',
mailboxPath: 'INBOX',
);
await repo.setFlag('er-acc:11', flagged: true); await repo.setFlag('er-acc:11', flagged: true);
final email = await repo.getEmail('er-acc:11'); final email = await repo.getEmail('er-acc:11');
expect(email!.isFlagged, isTrue); expect(email!.isFlagged, isTrue);
@@ -157,10 +147,7 @@ abstract class EmailRepositoryContract {
test('observeThreads starts empty', () async { test('observeThreads starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
}); });
} }
} }
+260 -199
View File
@@ -453,47 +453,103 @@ void main() {
expect(results.first.subject, 'foobar baz'); expect(results.first.subject, 'foobar baz');
}); });
test('searchAddresses returns results sorted by most recently used', test(
() async { 'searchAddresses returns results sorted by most recently used',
final r = _makeRepos(); () async {
await r.accounts.addAccount(_account, 'pw'); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
final older = DateTime(2024); final older = DateTime(2024);
final newer = DateTime(2024, 6); final newer = DateTime(2024, 6);
// Two emails — older one has alice@, newer one has bob@. // Two emails — older one has alice@, newer one has bob@.
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:old', id: 'acc-1:old',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 1, uid: 1,
receivedAt: older, receivedAt: older,
toAddresses: const Value( toAddresses: const Value(
'[{"name":"Alice","email":"alice@example.com"}]', '[{"name":"Alice","email":"alice@example.com"}]',
),
), ),
), );
); await r.db.into(r.db.emails).insert(
await r.db.into(r.db.emails).insert( EmailsCompanion.insert(
EmailsCompanion.insert( id: 'acc-1:new',
id: 'acc-1:new', accountId: 'acc-1',
accountId: 'acc-1', mailboxPath: 'Sent',
mailboxPath: 'Sent', uid: 2,
uid: 2, receivedAt: newer,
receivedAt: newer, toAddresses: const Value(
toAddresses: const Value( '[{"name":"Bob","email":"bob@example.com"}]',
'[{"name":"Bob","email":"bob@example.com"}]', ),
), ),
), );
);
// Query matching both; newer (bob) should come first. // Query matching both; newer (bob) should come first.
final results = await r.emails.searchAddresses(null, 'example'); final results = await r.emails.searchAddresses(null, 'example');
expect( expect(results.map((a) => a.email).toList(), [
results.map((a) => a.email).toList(), 'bob@example.com',
['bob@example.com', 'alice@example.com'], 'alice@example.com',
); ]);
}); },
);
test(
'searchAddresses prioritises sent-folder addresses over newer received',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Register the Sent mailbox so searchAddresses knows its role.
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
role: const Value('sent'),
),
);
// Older sent email: user deliberately wrote to info@foo.de.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:sent-1',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 1,
receivedAt: DateTime(2025),
toAddresses: const Value(
'[{"name":"Foo","email":"info@foo.de"}]',
),
),
);
// Newer received email: spam arrived today from info@spam.de.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:inbox-1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
receivedAt: DateTime(2026),
fromJson: const Value(
'[{"name":"Spam","email":"info@spam.de"}]',
),
),
);
// Even though spam is newer, the sent-folder address should win.
final results = await r.emails.searchAddresses(null, 'info');
expect(results.map((a) => a.email).toList(), [
'info@foo.de',
'info@spam.de',
]);
},
);
// ── IMAP method tests ──────────────────────────────────────────────────── // ── IMAP method tests ────────────────────────────────────────────────────
@@ -697,47 +753,47 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test('snooze flush selects src mailbox and moves email to Snoozed', test(
() async { 'snooze flush selects src mailbox and moves email to Snoozed',
final spy = SnoozeSpyImapClient(); () async {
final r = _makeRepos( final spy = SnoozeSpyImapClient();
imapConnect: (_, __, ___) async => spy, final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
); await r.accounts.addAccount(_account, 'pw');
await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert(
await r.db.into(r.db.emails).insert( EmailsCompanion.insert(
EmailsCompanion.insert( id: 'acc-1:5',
id: 'acc-1:5', accountId: 'acc-1',
accountId: 'acc-1', mailboxPath: 'Snoozed',
mailboxPath: 'Snoozed', uid: 5,
uid: 5, receivedAt: DateTime(2024),
receivedAt: DateTime(2024), ),
), );
); await r.db.into(r.db.pendingChanges).insert(
await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert(
PendingChangesCompanion.insert( accountId: 'acc-1',
accountId: 'acc-1', resourceType: 'Email',
resourceType: 'Email', resourceId: 'acc-1:5',
resourceId: 'acc-1:5', changeType: 'snooze',
changeType: 'snooze', payload: jsonEncode({
payload: jsonEncode({ 'uid': 5,
'uid': 5, 'src': 'INBOX',
'src': 'INBOX', 'dest': 'Snoozed',
'dest': 'Snoozed', 'until': '2026-05-10T15:00:00.000',
'until': '2026-05-10T15:00:00.000', }),
}), createdAt: DateTime.now(),
createdAt: DateTime.now(), ),
), );
);
await r.emails.flushPendingChanges('acc-1', 'pw'); await r.emails.flushPendingChanges('acc-1', 'pw');
// Change successfully applied — removed from queue. // Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Source mailbox extracted from 'src', not 'mailboxPath'. // Source mailbox extracted from 'src', not 'mailboxPath'.
expect(spy.selectedMailbox, 'INBOX'); expect(spy.selectedMailbox, 'INBOX');
expect(spy.createdMailbox, 'Snoozed'); expect(spy.createdMailbox, 'Snoozed');
expect(spy.movedToMailbox, 'Snoozed'); expect(spy.movedToMailbox, 'Snoozed');
}); },
);
}); });
group('Snooze', () { group('Snooze', () {
@@ -1640,119 +1696,123 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', test(
() async { 'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
final List<Map<String, dynamic>> capturedBodies = []; () async {
final client = MockClient((req) async { final List<Map<String, dynamic>> capturedBodies = [];
if (req.url.path.contains('well-known')) { final client = MockClient((req) async {
return http.Response( if (req.url.path.contains('well-known')) {
jsonEncode({ return http.Response(
'apiUrl': 'https://jmap.example.com/api/', jsonEncode({
'accounts': { 'apiUrl': 'https://jmap.example.com/api/',
'acct1': {'name': 'alice@example.com', 'isPersonal': true}, 'accounts': {
}, 'acct1': {'name': 'alice@example.com', 'isPersonal': true},
'primaryAccounts': { },
'urn:ietf:params:jmap:core': 'acct1', 'primaryAccounts': {
'urn:ietf:params:jmap:mail': 'acct1', 'urn:ietf:params:jmap:core': 'acct1',
}, 'urn:ietf:params:jmap:mail': 'acct1',
'capabilities': {}, },
'username': 'alice@example.com', 'capabilities': {},
'state': 'sess1', 'username': 'alice@example.com',
}), 'state': 'sess1',
200, }),
); 200,
} );
final body = jsonDecode(req.body) as Map<String, dynamic>; }
capturedBodies.add(body); final body = jsonDecode(req.body) as Map<String, dynamic>;
final calls = body['methodCalls'] as List; capturedBodies.add(body);
final methodName = (calls.first as List)[0] as String; final calls = body['methodCalls'] as List;
if (methodName == 'Mailbox/set') { final methodName = (calls.first as List)[0] as String;
if (methodName == 'Mailbox/set') {
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0',
],
],
}),
200,
);
}
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
'sessionState': 's1', 'sessionState': 's1',
'methodResponses': [ 'methodResponses': [
[ [
'Mailbox/set', 'Email/set',
{ {'accountId': 'acct1', 'updated': {}},
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0', '0',
], ],
], ],
}), }),
200, 200,
); );
} });
return http.Response(
jsonEncode({ final r = _makeRepos(httpClient: client);
'sessionState': 's1', await seedChange(
'methodResponses': [ r.db,
[ r.accounts,
'Email/set', changeType: 'snooze',
{'accountId': 'acct1', 'updated': {}}, payload: jsonEncode({
'0', 'uid': 0,
], 'src': 'mbx-inbox',
], 'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}), }),
200,
); );
});
final r = _makeRepos(httpClient: client); await r.emails.flushPendingChanges('jmap-1', 'pw');
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
await r.emails.flushPendingChanges('jmap-1', 'pw'); // Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Change successfully applied — removed from queue. // First API call should be Mailbox/set to create the Snoozed folder.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(capturedBodies, hasLength(2));
final firstCall =
((capturedBodies.first['methodCalls'] as List).first as List)[0];
expect(firstCall, 'Mailbox/set');
// First API call should be Mailbox/set to create the Snoozed folder. // Second call should be Email/set using the newly created mailbox ID.
expect(capturedBodies, hasLength(2)); final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
final firstCall = as List)[1] as Map<String, dynamic>;
((capturedBodies.first['methodCalls'] as List).first as List)[0]; final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
expect(firstCall, 'Mailbox/set'); as Map<String, dynamic>;
expect(update['mailboxIds/mbx-snoozed'], true);
},
);
// Second call should be Email/set using the newly created mailbox ID. test(
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first 'snooze uses existing mailbox ID when dest is already a JMAP ID',
as List)[1] as Map<String, dynamic>; () async {
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1'] final r = _makeRepos(httpClient: mockFlush(200));
as Map<String, dynamic>; await seedChange(
expect(update['mailboxIds/mbx-snoozed'], true); r.db,
}); r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'mbx-snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
test('snooze uses existing mailbox ID when dest is already a JMAP ID', await r.emails.flushPendingChanges('jmap-1', 'pw');
() async {
final r = _makeRepos(httpClient: mockFlush(200));
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'mbx-snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
await r.emails.flushPendingChanges('jmap-1', 'pw'); // Change applied without needing Mailbox/set (dest was already a valid ID).
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Change applied without needing Mailbox/set (dest was already a valid ID). },
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); );
});
}); });
group('JMAP syncEmails body caching', () { group('JMAP syncEmails body caching', () {
@@ -2282,41 +2342,42 @@ void main() {
group('concurrent moves', () { group('concurrent moves', () {
test( test(
'two simultaneous moves enqueue two changes and leave email in last destination', 'two simultaneous moves enqueue two changes and leave email in last destination',
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 5, uid: 5,
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
// Fire both moves without awaiting to exercise concurrent enqueue logic. // Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive'); final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash'); final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]); await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2)); expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move')); expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations = final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet(); changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash'])); expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
expect( expect(
email!.mailboxPath, email!.mailboxPath,
anyOf('Archive', 'Trash'), anyOf('Archive', 'Trash'),
reason: reason:
'email must be optimistically moved to one of the two destinations', 'email must be optimistically moved to one of the two destinations',
); );
}); },
);
}); });
group('IMAP SMTP auth failure', () { group('IMAP SMTP auth failure', () {
@@ -61,10 +61,7 @@ abstract class MailboxRepositoryContract {
test('findMailboxByRole returns null when no match', () async { test('findMailboxByRole returns null when no match', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull);
await repo.findMailboxByRole(_account.id, 'archive'),
isNull,
);
}); });
test('findMailboxByRole returns the matching mailbox', () async { test('findMailboxByRole returns the matching mailbox', () async {
+69 -67
View File
@@ -486,8 +486,11 @@ void main() {
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes final result = await r.mailboxes.createMailboxWithRole(
.createMailboxWithRole('jmap-1', 'Archive', 'archive'); 'jmap-1',
'Archive',
'archive',
);
expect(result.name, 'Archive'); expect(result.name, 'Archive');
expect(result.role, 'archive'); expect(result.role, 'archive');
@@ -498,81 +501,80 @@ void main() {
expect(found!.name, 'Archive'); expect(found!.name, 'Archive');
}); });
test( test('JMAP: throws when server returns no created ID', () async {
'JMAP: throws when server returns no created ID', final r = _makeRepos(
() async { httpClient: _mockJmap(
final r = _makeRepos( apiResponses: [
httpClient: _mockJmap( {
apiResponses: [ 'sessionState': 'sess1',
{ 'methodResponses': [
'sessionState': 'sess1', [
'methodResponses': [ 'Mailbox/set',
[ {
'Mailbox/set', 'accountId': 'acct1',
{ 'created': null,
'accountId': 'acct1', 'notCreated': {
'created': null, 'new-mailbox': {'type': 'serverFail'},
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
}, },
'0', },
], '0',
], ],
}, ],
], },
), ],
); ),
await r.accounts.addAccount(_jmapAccount, 'pw'); );
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater( await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'), r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()), throwsA(isA<Exception>()),
); );
}, });
);
}); });
group('syncMailboxes IMAP preserves manually-set role', () { group('syncMailboxes IMAP preserves manually-set role', () {
test('existing role is kept when server returns no special-use flag', test(
() async { 'existing role is kept when server returns no special-use flag',
final spy = SnoozeSpyImapClient(); () async {
// Make listMailboxes return a plain folder without \Archive. final spy = SnoozeSpyImapClient();
final db = openTestDatabase(); // Make listMailboxes return a plain folder without \Archive.
final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder. // Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient(); final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl( final mailboxes = MailboxRepositoryImpl(
db, db,
accounts, accounts,
imapConnect: (_, __, ___) async => fakeClient, imapConnect: (_, __, ___) async => fakeClient,
); );
await accounts.addAccount(_account, 'pw'); await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder). // Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert( await db.into(db.mailboxes).insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:Archive', id: 'acc-1:Archive',
accountId: 'acc-1', accountId: 'acc-1',
path: 'Archive', path: 'Archive',
name: 'Archive', name: 'Archive',
role: const Value('archive'), role: const Value('archive'),
), ),
); );
await mailboxes.syncMailboxes('acc-1'); await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive'); final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect( expect(
found, found,
isNotNull, isNotNull,
reason: 'Manually-set role should be preserved after sync', reason: 'Manually-set role should be preserved after sync',
); );
expect(found!.path, 'Archive'); expect(found!.path, 'Archive');
// Suppress unused warning on spy. // Suppress unused warning on spy.
expect(spy, isNotNull); expect(spy, isNotNull);
}); },
);
}); });
}); });
} }
+96 -80
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 36); expect(db.schemaVersion, 37);
await db.close(); await db.close();
}); });
@@ -178,17 +178,17 @@ void main() {
// v28: mime_tree_json column on email_bodies. // v28: mime_tree_json column on email_bodies.
await db await db
.customSelect( .customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.get(); .get();
// v29: local_sieve_scripts table. // v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = final syncLogMailboxColumns = await _tableColumns(
await _tableColumns(db, 'sync_log_mailboxes'); db,
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
@@ -209,19 +209,22 @@ void main() {
// v36: after_mail_view_action column on user_preferences. // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action')); expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test( test(
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id', 'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async { () async {
final dbFile = File('test_migration_v22.db'); final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL. // Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path); final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute(''' rawDb.execute('''
CREATE TABLE accounts ( CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
@@ -242,7 +245,7 @@ void main() {
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1)) verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE drafts ( CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL, account_id TEXT NULL,
@@ -254,7 +257,7 @@ void main() {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE mailboxes ( CREATE TABLE mailboxes (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -265,7 +268,7 @@ void main() {
role TEXT NULL role TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE emails ( CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -289,7 +292,7 @@ void main() {
snoozed_from_mailbox_path TEXT NULL snoozed_from_mailbox_path TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE threads ( CREATE TABLE threads (
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL, mailbox_path TEXT NOT NULL,
@@ -306,7 +309,7 @@ void main() {
PRIMARY KEY (account_id, mailbox_path, id) PRIMARY KEY (account_id, mailbox_path, id)
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE email_bodies ( CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE, email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
text_body TEXT NULL, text_body TEXT NULL,
@@ -316,7 +319,7 @@ void main() {
headers_json TEXT NULL headers_json TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE sync_logs ( CREATE TABLE sync_logs (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -333,7 +336,7 @@ void main() {
protocol_log TEXT NULL protocol_log TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE sync_log_mailboxes ( CREATE TABLE sync_log_mailboxes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE, sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
@@ -343,79 +346,86 @@ void main() {
bytes_transferred INTEGER NOT NULL DEFAULT 0 bytes_transferred INTEGER NOT NULL DEFAULT 0
); );
'''); ''');
rawDb.execute('PRAGMA user_version = 22;'); rawDb.execute('PRAGMA user_version = 22;');
rawDb.close(); rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile)); final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration. // Trigger migration.
await db.select(db.accounts).get(); await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails'); final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header')); expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts'); final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id')); expect(draftColumns, contains('imap_server_id'));
// v25: new indexes on mailboxes and threads. // v25: new indexes on mailboxes and threads.
final allIndexes = await db final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'") .customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get(); .get();
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet(); final indexNames =
expect(indexNames, contains('mailboxes_account_id')); allIndexes.map((r) => r.read<String>('name')).toSet();
expect(indexNames, contains('threads_latest_date')); expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('threads_latest_date'));
// v26: FTS5 virtual table and triggers. // v26: FTS5 virtual table and triggers.
final allTriggers = await db final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get(); .get();
final triggerNames = final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet(); allTriggers.map((r) => r.read<String>('name')).toSet();
expect( expect(
triggerNames, triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
); );
await db.customSelect('SELECT count(*) FROM email_fts').get(); await db.customSelect('SELECT count(*) FROM email_fts').get();
// v27: search_history_entries table. // v27: search_history_entries table.
await db await db
.customSelect('SELECT count(*) FROM search_history_entries') .customSelect('SELECT count(*) FROM search_history_entries')
.get(); .get();
// v28: mime_tree_json column on email_bodies. // v28: mime_tree_json column on email_bodies.
await db await db
.customSelect( .customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
'SELECT mime_tree_json FROM email_bodies LIMIT 0', .get();
)
.get();
// v29: local_sieve_scripts table. // v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = final syncLogMailboxColumns = await _tableColumns(
await _tableColumns(db, 'sync_log_mailboxes'); db,
expect(syncLogMailboxColumns, contains('duration_ms')); 'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs. // v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs'); final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace')); expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent')); expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table. // v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get(); await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences. // v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences'); final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position')); expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences. // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action')); expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); // v37: image_trusted_senders table.
if (dbFile.existsSync()) dbFile.deleteSync(); await db
}); .customSelect('SELECT count(*) FROM image_trusted_senders')
.get();
test('fresh install creates all tables at schemaVersion 36', () async { await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 37', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -443,6 +453,7 @@ void main() {
'share_keys', // v31 'share_keys', // v31
'local_sieve_applied', // v32 'local_sieve_applied', // v32
'user_preferences', // v34 'user_preferences', // v34
'image_trusted_senders', // v37
]), ]),
); );
@@ -453,8 +464,10 @@ void main() {
expect(draftColumns, contains('imap_server_id')); expect(draftColumns, contains('imap_server_id'));
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = final syncLogMailboxColumns = await _tableColumns(
await _tableColumns(db, 'sync_log_mailboxes'); db,
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs. // v33: error_stack_trace and is_permanent columns on sync_logs.
@@ -469,6 +482,9 @@ void main() {
// v36: after_mail_view_action column on user_preferences. // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action')); expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
await db.close(); await db.close();
}); });
}); });
+9 -8
View File
@@ -9,14 +9,15 @@ void main() {
// absent at startup, throwing MissingPluginException (or a similar error). // absent at startup, throwing MissingPluginException (or a similar error).
// initNotifications() must absorb the failure and let the app continue. // initNotifications() must absorb the failure and let the app continue.
test( test(
'initNotifications completes without throwing when plugin is unavailable', 'initNotifications completes without throwing when plugin is unavailable',
() async { () async {
// In the unit-test environment the native plugin is not registered, so // In the unit-test environment the native plugin is not registered, so
// _plugin.initialize() throws. The fix catches it and keeps _initialized // _plugin.initialize() throws. The fix catches it and keeps _initialized
// false. This test fails before the fix (exception propagates) and passes // false. This test fails before the fix (exception propagates) and passes
// after it (exception is swallowed). // after it (exception is swallowed).
await expectLater(initNotifications(), completes); await expectLater(initNotifications(), completes);
}); },
);
test('showNewMailNotification completes without throwing', () async { test('showNewMailNotification completes without throwing', () async {
// Platform.isAndroid is false in tests, so this returns early without // Platform.isAndroid is false in tests, so this returns early without
@@ -103,6 +103,9 @@ class _FakeEmails implements EmailRepository {
}) => }) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
@override @override
+7 -10
View File
@@ -26,11 +26,9 @@ class _FakeAccounts implements AccountRepository {
@override @override
Stream<List<Account>> observeAccounts() => Stream.value(accounts); Stream<List<Account>> observeAccounts() => Stream.value(accounts);
@override @override
Future<Account?> getAccount(String id) async => Future<Account?> getAccount(String id) async => accounts
accounts.cast<Account?>().firstWhere( .cast<Account?>()
(a) => a?.id == id, .firstWhere((a) => a?.id == id, orElse: () => null);
orElse: () => null,
);
@override @override
Future<void> addAccount(Account account, String password) async {} Future<void> addAccount(Account account, String password) async {}
@override @override
@@ -94,11 +92,7 @@ class _CountingEmails implements EmailRepository {
@override @override
Future<int> flushPendingChanges(String accountId, String password) async => 0; Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
@@ -108,6 +102,9 @@ class _CountingEmails implements EmailRepository {
}) => }) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
Stream.value([]); Stream.value([]);
@override @override
+1 -3
View File
@@ -47,9 +47,7 @@ void main() {
test('parsePublicKeyQr returns null for invalid input', () { test('parsePublicKeyQr returns null for invalid input', () {
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull); expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
expect( expect(
ShareEncryptionService.parsePublicKeyQr( ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
'sharedinbox.de:pubkey:v1:!!!',
),
isNull, isNull,
); );
expect( expect(
+5 -7
View File
@@ -73,11 +73,7 @@ void main() {
SieveRule( SieveRule(
joinType: 'single', joinType: 'single',
conditions: [ conditions: [
HeaderCondition( HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
['from', 'reply-to'],
':is',
['boss@work.com'],
),
], ],
actions: [ actions: [
FlagAction([r'\Important']), FlagAction([r'\Important']),
@@ -121,8 +117,10 @@ void main() {
), ),
]; ];
final ctx = final ctx = interp.execute(
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue')); rules,
_email(subject: 'Weekly Newsletter Issue'),
);
expect(ctx.targetFolders, contains('Bulk')); expect(ctx.targetFolders, contains('Bulk'));
}); });
}); });
+3 -2
View File
@@ -261,8 +261,9 @@ if exists "X-Spam-Flag" {
group('SieveParser — rule model', () { group('SieveParser — rule model', () {
test('simple if produces one rule with branchGroupId', () { test('simple if produces one rule with branchGroupId', () {
final rules = final rules = parser.parse(
parser.parse('if header :contains "Subject" "x" { discard; }'); 'if header :contains "Subject" "x" { discard; }',
);
expect(rules, hasLength(1)); expect(rules, hasLength(1));
expect(rules.first.branchGroupId, isNotNull); expect(rules.first.branchGroupId, isNotNull);
expect(rules.first.conditions, hasLength(1)); expect(rules.first.conditions, hasLength(1));
+29 -27
View File
@@ -127,33 +127,35 @@ void main() {
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
}); });
test('stores and retrieves stackTrace and isPermanent on error entries', test(
() async { 'stores and retrieves stackTrace and isPermanent on error entries',
final repo = SyncLogRepositoryImpl(db); () async {
final start = DateTime(2024, 3, 1, 9); final repo = SyncLogRepositoryImpl(db);
final end = DateTime(2024, 3, 1, 9, 0, 1); final start = DateTime(2024, 3, 1, 9);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)'; final end = DateTime(2024, 3, 1, 9, 0, 1);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
await repo.log( await repo.log(
accountId: 'acc1', accountId: 'acc1',
success: false, success: false,
errorMessage: 'MissingPluginException', errorMessage: 'MissingPluginException',
stackTrace: fakeTrace, stackTrace: fakeTrace,
isPermanent: true, isPermanent: true,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
mailboxesSynced: 0, mailboxesSynced: 0,
pendingFlushed: 0, pendingFlushed: 0,
bytesTransferred: 0, bytesTransferred: 0,
startedAt: start, startedAt: start,
finishedAt: end, finishedAt: end,
); );
final entries = await repo.observeSyncLogs('acc1').first; final entries = await repo.observeSyncLogs('acc1').first;
final entry = entries.firstWhere((e) => e.startedAt == start); final entry = entries.firstWhere((e) => e.startedAt == start);
expect(entry.stackTrace, fakeTrace); expect(entry.stackTrace, fakeTrace);
expect(entry.isPermanent, true); expect(entry.isPermanent, true);
expect(entry.errorMessage, 'MissingPluginException'); expect(entry.errorMessage, 'MissingPluginException');
}); },
);
} }
+6 -4
View File
@@ -260,8 +260,9 @@ void main() {
expect(original!.messageId, isNull); // set a messageId so lookup works expect(original!.messageId, isNull); // set a messageId so lookup works
// Seed a messageId so undo can find the email after UID change. // Seed a messageId so undo can find the email after UID change.
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))) await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write(
.write(const EmailsCompanion(messageId: Value('msg-101@test'))); const EmailsCompanion(messageId: Value('msg-101@test')),
);
final originalWithMsgId = await repo.getEmail(oldEmailId); final originalWithMsgId = await repo.getEmail(oldEmailId);
@@ -303,8 +304,9 @@ void main() {
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 4. Verify the current email row is now in INBOX. // 4. Verify the current email row is now in INBOX.
final inInbox = await (db.select(db.emails) final inInbox = await (db.select(
..where((t) => t.mailboxPath.equals('INBOX'))) db.emails,
)..where((t) => t.mailboxPath.equals('INBOX')))
.get(); .get();
expect( expect(
inInbox, inInbox,
+64 -64
View File
@@ -122,70 +122,74 @@ void main() {
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
}); });
test('undo pushes inverse action into log when destinationMailboxPath is set', test(
() async { 'undo pushes inverse action into log when destinationMailboxPath is set',
final action = UndoAction( () async {
id: 'del1', final action = UndoAction(
accountId: 'acc1', id: 'del1',
type: UndoType.delete, accountId: 'acc1',
emailIds: ['e1'], type: UndoType.delete,
sourceMailboxPath: 'INBOX', emailIds: ['e1'],
destinationMailboxPath: 'Trash', sourceMailboxPath: 'INBOX',
); destinationMailboxPath: 'Trash',
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when( when(
mockEmailRepo.cancelPendingChange(any, any), mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
await notifier.pushAction(action); await notifier.pushAction(action);
await notifier.undo(actionId: 'del1'); await notifier.undo(actionId: 'del1');
// Original entry stays; inverse is added. // Original entry stays; inverse is added.
final log = container.read(undoServiceProvider); final log = container.read(undoServiceProvider);
expect(log.length, 2); expect(log.length, 2);
expect(log[0].id, 'del1'); expect(log[0].id, 'del1');
final inv = log[1]; final inv = log[1];
expect(inv.id, 'del1-inv'); expect(inv.id, 'del1-inv');
expect(inv.type, UndoType.move); expect(inv.type, UndoType.move);
expect(inv.emailIds, ['e1']); expect(inv.emailIds, ['e1']);
expect(inv.sourceMailboxPath, 'Trash'); expect(inv.sourceMailboxPath, 'Trash');
expect(inv.destinationMailboxPath, 'INBOX'); expect(inv.destinationMailboxPath, 'INBOX');
verify( verify(
mockUndoRepo.saveAction( mockUndoRepo.saveAction(
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')), argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
), ),
).called(1); ).called(1);
}); },
);
test('undo without destinationMailboxPath does not push inverse action', test(
() async { 'undo without destinationMailboxPath does not push inverse action',
final action = UndoAction( () async {
id: 'mv1', final action = UndoAction(
accountId: 'acc1', id: 'mv1',
type: UndoType.move, accountId: 'acc1',
emailIds: ['e1'], type: UndoType.move,
sourceMailboxPath: 'INBOX', emailIds: ['e1'],
// no destinationMailboxPath sourceMailboxPath: 'INBOX',
); // no destinationMailboxPath
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when( when(
mockEmailRepo.cancelPendingChange(any, any), mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
await notifier.pushAction(action); await notifier.pushAction(action);
await notifier.undo(actionId: 'mv1'); await notifier.undo(actionId: 'mv1');
// Original entry stays; no inverse since no destinationMailboxPath. // Original entry stays; no inverse since no destinationMailboxPath.
final log = container.read(undoServiceProvider); final log = container.read(undoServiceProvider);
expect(log.length, 1); expect(log.length, 1);
expect(log.first.id, 'mv1'); expect(log.first.id, 'mv1');
}); },
);
test('undo with actionId removes and undos specific action', () async { test('undo with actionId removes and undos specific action', () async {
// action1 has no destination → no inverse action // action1 has no destination → no inverse action
@@ -350,13 +354,9 @@ void main() {
); );
// Simulate slow DB load // Simulate slow DB load
when( when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer(
mockUndoRepo.getHistory(limit: anyNamed('limit')), (_) =>
).thenAnswer( Future.delayed(const Duration(milliseconds: 10), () => [persisted]),
(_) => Future.delayed(
const Duration(milliseconds: 10),
() => [persisted],
),
); );
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
+11
View File
@@ -109,6 +109,17 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(), returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>); ) as _i4.Stream<List<_i2.EmailThread>>);
@override
_i4.Stream<List<_i2.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
(super.noSuchMethod(
Invocation.method(
#observeAllInboxThreads,
[],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>);
@override @override
_i4.Stream<List<_i2.Email>> observeEmailsInThread( _i4.Stream<List<_i2.Email>> observeEmailsInThread(
String? accountId, String? accountId,
+8 -8
View File
@@ -46,8 +46,9 @@ class ThrowingUrlLauncher extends Mock
Widget _buildScreen({List<Account> accounts = const []}) { Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository(accounts)), FakeAccountRepository(accounts),
),
], ],
child: const MaterialApp(home: AboutScreen()), child: const MaterialApp(home: AboutScreen()),
); );
@@ -151,8 +152,10 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler(SystemChannels.platform, null), SystemChannels.platform,
null,
),
); );
await tester.pumpWidget(_buildScreen()); await tester.pumpWidget(_buildScreen());
@@ -173,10 +176,7 @@ void main() {
expect(clipboardText, contains('Locale')); expect(clipboardText, contains('Locale'));
expect(clipboardText, contains('Text Scale')); expect(clipboardText, contains('Text Scale'));
expect(clipboardText, contains('DB Schema Version')); expect(clipboardText, contains('DB Schema Version'));
expect( expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)'));
clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'),
);
}); });
testWidgets('AboutScreen create-issue button opens Codeberg URL', ( testWidgets('AboutScreen create-issue button opens Codeberg URL', (
+2 -8
View File
@@ -74,10 +74,7 @@ void main() {
recipientKeyId: material.keyId, recipientKeyId: material.keyId,
recipientPublicKeyBytes: material.publicKeyBytes, recipientPublicKeyBytes: material.publicKeyBytes,
accounts: [ accounts: [
AccountPayload( AccountPayload(accountJson: account.toJson(), password: 'secret'),
accountJson: account.toJson(),
password: 'secret',
),
], ],
); );
@@ -99,10 +96,7 @@ void main() {
await tester.tap(find.text('Import')); await tester.tap(find.text('Import'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.text('Imported 1 account successfully.'), findsOneWidget);
find.text('Imported 1 account successfully.'),
findsOneWidget,
);
}, },
); );
+41 -42
View File
@@ -227,54 +227,53 @@ void main() {
expect(find.textContaining('Healthy'), findsOneWidget); expect(find.textContaining('Healthy'), findsOneWidget);
}); });
testWidgets( testWidgets('shows discrepancy details when sync health has discrepancies',
'shows discrepancy details when sync health has discrepancies', (
(tester) async { tester,
const summary = ) async {
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; const summary =
await tester.pumpWidget( '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
buildApp( await tester.pumpWidget(
initialLocation: '/accounts', buildApp(
overrides: baseOverrides( initialLocation: '/accounts',
accounts: [kTestAccount], overrides: baseOverrides(
syncHealth: SyncHealthRow( accounts: [kTestAccount],
accountId: kTestAccount.id, syncHealth: SyncHealthRow(
lastVerifiedAt: DateTime(2024, 6), accountId: kTestAccount.id,
isHealthy: false, lastVerifiedAt: DateTime(2024, 6),
discrepancySummary: summary, isHealthy: false,
), discrepancySummary: summary,
), ),
), ),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
expect(find.textContaining('missing locally: 3'), findsOneWidget); expect(find.textContaining('missing locally: 3'), findsOneWidget);
expect(find.textContaining('flag mismatches: 1'), findsOneWidget); expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
}, });
);
testWidgets( testWidgets('sync health row is positioned below the account name row', (
'sync health row is positioned below the account name row', tester,
(tester) async { ) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts', initialLocation: '/accounts',
overrides: baseOverrides( overrides: baseOverrides(
accounts: [kTestAccount], accounts: [kTestAccount],
syncHealth: SyncHealthRow( syncHealth: SyncHealthRow(
accountId: kTestAccount.id, accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6), lastVerifiedAt: DateTime(2024, 6),
isHealthy: true, isHealthy: true,
),
), ),
), ),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy; final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy; final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos)); expect(healthPos, greaterThan(namePos));
}, });
);
}); });
} }
+68 -66
View File
@@ -96,8 +96,10 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler(SystemChannels.platform, null), SystemChannels.platform,
null,
),
); );
const exception = 'TestException: clipboard test'; const exception = 'TestException: clipboard test';
@@ -126,79 +128,77 @@ void main() {
}, },
); );
testWidgets( testWidgets('CrashScreen shows git hash as clickable link above stacktrace', (
'CrashScreen shows git hash as clickable link above stacktrace', tester,
(tester) async { ) async {
tester.view.physicalSize = const Size(800, 1200); tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize()); addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher(); final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock; UrlLauncherPlatform.instance = mock;
const exception = 'TestException: git hash test'; const exception = 'TestException: git hash test';
final stackTrace = StackTrace.current; final stackTrace = StackTrace.current;
const testHash = 'abc1234'; const testHash = 'abc1234';
await tester.pumpWidget( await tester.pumpWidget(
CrashScreen( CrashScreen(
exception: exception, exception: exception,
stackTrace: stackTrace, stackTrace: stackTrace,
gitHash: testHash, gitHash: testHash,
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Git hash link should be present // Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234'); final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(gitLinkFinder, findsOneWidget); expect(gitLinkFinder, findsOneWidget);
// Link must appear above the stack trace // Link must appear above the stack trace
final stackTraceFinder = find.text('Stack Trace:'); final stackTraceFinder = find.text('Stack Trace:');
expect( expect(
tester.getTopLeft(gitLinkFinder).dy, tester.getTopLeft(gitLinkFinder).dy,
lessThan(tester.getTopLeft(stackTraceFinder).dy), lessThan(tester.getTopLeft(stackTraceFinder).dy),
); );
// Tapping the link should open the Codeberg commit URL // Tapping the link should open the Codeberg commit URL
await tester.tap(gitLinkFinder); await tester.tap(gitLinkFinder);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
mock.launchedUrl, mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'), equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
); );
}, });
);
testWidgets( testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
'CrashScreen shows version, build mode, and platform in the UI', tester,
(tester) async { ) async {
tester.view.physicalSize = const Size(800, 1200); tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize()); addTearDown(() => tester.view.resetPhysicalSize());
const exception = 'TestException: info row test'; const exception = 'TestException: info row test';
final stackTrace = StackTrace.current; final stackTrace = StackTrace.current;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace), home: CrashScreen(exception: exception, stackTrace: stackTrace),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Info row shows app version (from mock), build mode, and platform OS. // Info row shows app version (from mock), build mode, and platform OS.
expect(find.textContaining('1.0.0+42'), findsWidgets); expect(find.textContaining('1.0.0+42'), findsWidgets);
// In test builds kDebugMode is true. // In test builds kDebugMode is true.
expect(find.textContaining('debug'), findsOneWidget); expect(find.textContaining('debug'), findsOneWidget);
// Platform OS is always present (linux in CI, android/ios on device). // Platform OS is always present (linux in CI, android/ios on device).
expect( expect(
find.textContaining(RegExp(r'linux|android|ios|windows|macos')), find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
findsWidgets, findsWidgets,
); );
}, });
);
testWidgets( testWidgets(
'CrashScreen shows app version as clickable link when git hash is set', 'CrashScreen shows app version as clickable link when git hash is set',
@@ -264,8 +264,10 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler(SystemChannels.platform, null), SystemChannels.platform,
null,
),
); );
const exception = 'TestException: version link clipboard test'; const exception = 'TestException: version link clipboard test';
+50 -49
View File
@@ -106,62 +106,62 @@ void main() {
}); });
testWidgets( testWidgets(
'try connection button is disabled when no password stored or entered', 'try connection button is disabled when no password stored or entered',
( (tester) async {
tester, tester.view.physicalSize = const Size(800, 1400);
) async { tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(800, 1400); addTearDown(tester.view.resetPhysicalSize);
tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/edit', initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides( overrides: baseOverrides(
accounts: [kTestAccount], accounts: [kTestAccount],
hasStoredPassword: false, hasStoredPassword: false,
),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
final button = tester.widget<OutlinedButton>( final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')), find.byKey(const Key('editTryConnectionButton')),
); );
expect(button.onPressed, isNull); expect(button.onPressed, isNull);
}); },
);
testWidgets( testWidgets(
'try connection button is enabled after typing password with no stored password', 'try connection button is enabled after typing password with no stored password',
(tester) async { (tester) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/edit', initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides( overrides: baseOverrides(
accounts: [kTestAccount], accounts: [kTestAccount],
hasStoredPassword: false, hasStoredPassword: false,
),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
await tester.enterText( await tester.enterText(
find.byKey(const Key('editPasswordField')), find.byKey(const Key('editPasswordField')),
'mypassword', 'mypassword',
); );
await tester.pump(); await tester.pump();
final button = tester.widget<OutlinedButton>( final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')), find.byKey(const Key('editTryConnectionButton')),
); );
expect(button.onPressed, isNotNull); expect(button.onPressed, isNotNull);
}); },
);
testWidgets('save button is disabled when no password stored or entered', ( testWidgets('save button is disabled when no password stored or entered', (
tester, tester,
@@ -182,8 +182,9 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final button = tester final button = tester.widget<FilledButton>(
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save')); find.widgetWithText(FilledButton, 'Save'),
);
expect(button.onPressed, isNull); expect(button.onPressed, isNull);
}); });
+107 -118
View File
@@ -52,10 +52,7 @@ List<Override> _overrides({required EmailBody body, Email? email}) => [
), ),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
emailDetail: email ?? testEmail(),
emailBody: body,
),
), ),
]; ];
@@ -191,45 +188,45 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'),
(w) => w is Tooltip && w.message == 'Reply all',
),
findsNothing, findsNothing,
); );
}); });
testWidgets('Reply on single-recipient email navigates directly to compose', testWidgets(
(tester) async { 'Reply on single-recipient email navigates directly to compose',
// testEmail has from=[bob], to=[alice]. After removing alice (own), (tester) async {
// only bob remains → no dialog, navigate straight to compose. // testEmail has from=[bob], to=[alice]. After removing alice (own),
final email = testEmail(); // only bob remains → no dialog, navigate straight to compose.
await tester.pumpWidget( final email = testEmail();
buildApp( await tester.pumpWidget(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', buildApp(
overrides: [ initialLocation:
..._overrides( '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
body: const EmailBody(emailId: 'acc-1:42', attachments: []), overrides: [
email: email, ..._overrides(
), body: const EmailBody(emailId: 'acc-1:42', attachments: []),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), email: email,
], ),
), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
); ],
await tester.pumpAndSettle(); ),
);
await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
(w) => w is Tooltip && w.message == 'Reply', );
), await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
// No dialog shown — straight navigation to compose. // No dialog shown — straight navigation to compose.
expect(find.text('Reply All'), findsNothing); expect(find.text('Reply All'), findsNothing);
}); },
);
testWidgets('Reply on multi-recipient email shows Reply All dialog', testWidgets('Reply on multi-recipient email shows Reply All dialog', (
(tester) async { tester,
) async {
// Email with an extra Cc recipient so the dialog is triggered. // Email with an extra Cc recipient so the dialog is triggered.
final email = Email( final email = Email(
id: 'acc-1:42', id: 'acc-1:42',
@@ -258,9 +255,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
(w) => w is Tooltip && w.message == 'Reply',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -271,8 +266,9 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1)); expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
}); });
testWidgets('Mark as spam is in popup menu, not a standalone button', testWidgets('Mark as spam is in popup menu, not a standalone button', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -298,8 +294,9 @@ void main() {
expect(find.text('Mark as spam'), findsOneWidget); expect(find.text('Mark as spam'), findsOneWidget);
}); });
testWidgets('Mark as spam shows dialog when no junk folder', testWidgets('Mark as spam shows dialog when no junk folder', (
(tester) async { tester,
) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole // FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown. // returns null → dialog shown.
await tester.pumpWidget( await tester.pumpWidget(
@@ -334,9 +331,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget, findsOneWidget,
); );
}); });
@@ -355,17 +350,16 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
(w) => w is Tooltip && w.message == 'Archive',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget); expect(find.text('No archive folder found'), findsOneWidget);
}); });
testWidgets('Mark as unread is in popup menu, not a standalone button', testWidgets('Mark as unread is in popup menu, not a standalone button', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -401,13 +395,16 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider mailboxRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeMailboxRepository()), FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
emailDetail: testEmail(), emailDetail: testEmail(),
emailBody: emailBody: const EmailBody(
const EmailBody(emailId: 'acc-1:42', attachments: []), emailId: 'acc-1:42',
attachments: [],
),
rawRfc822: rawContent, rawRfc822: rawContent,
), ),
), ),
@@ -436,13 +433,16 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider mailboxRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeMailboxRepository()), FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
emailDetail: testEmail(), emailDetail: testEmail(),
emailBody: emailBody: const EmailBody(
const EmailBody(emailId: 'acc-1:42', attachments: []), emailId: 'acc-1:42',
attachments: [],
),
rawRfc822: 'Subject: test\r\n\r\nBody', rawRfc822: 'Subject: test\r\n\r\nBody',
), ),
), ),
@@ -483,43 +483,37 @@ void main() {
expect(find.text('Share'), findsOneWidget); expect(find.text('Share'), findsOneWidget);
}); });
testWidgets( testWidgets('long-press on unsubscribe chip shows URL tooltip', (
'long-press on unsubscribe chip shows URL tooltip', tester,
(tester) async { ) async {
final email = testEmail( final email = testEmail(
listUnsubscribeHeader: '<https://example.com/unsubscribe>', listUnsubscribeHeader: '<https://example.com/unsubscribe>',
); );
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: _overrides(
overrides: _overrides( body: const EmailBody(emailId: 'acc-1:42', attachments: []),
body: const EmailBody(emailId: 'acc-1:42', attachments: []), email: email,
email: email,
),
), ),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
expect(find.text('Unsubscribe'), findsOneWidget); expect(find.text('Unsubscribe'), findsOneWidget);
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate(
(w) => (w) => w is Tooltip && w.message == 'https://example.com/unsubscribe',
w is Tooltip && w.message == 'https://example.com/unsubscribe', ),
), findsOneWidget,
findsOneWidget, );
);
await tester.longPress(find.text('Unsubscribe')); await tester.longPress(find.text('Unsubscribe'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.text('https://example.com/unsubscribe'), findsOneWidget);
find.text('https://example.com/unsubscribe'), });
findsOneWidget,
);
},
);
testWidgets('Show Mail Structure opens dialog with MIME parts', ( testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester, tester,
@@ -563,36 +557,31 @@ void main() {
expect(find.textContaining('application/pdf'), findsOneWidget); expect(find.textContaining('application/pdf'), findsOneWidget);
}); });
testWidgets( testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', (
'Show Mail Structure shows snackbar when mimeTree is absent', tester,
(tester) async { ) async {
const body = EmailBody( const body = EmailBody(
emailId: 'acc-1:42', emailId: 'acc-1:42',
textBody: 'Hello', textBody: 'Hello',
attachments: [], attachments: [],
// mimeTree is null — not yet cached or not available. // mimeTree is null — not yet cached or not available.
); );
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: _overrides(body: body),
overrides: _overrides(body: body), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>)); await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Show Mail Structure')); await tester.tap(find.text('Show Mail Structure'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.textContaining('Structure not available'), findsOneWidget);
find.textContaining('Structure not available'), });
findsOneWidget,
);
},
);
}); });
} }
@@ -51,9 +51,7 @@ List<Override> _overrides({
searchHistoryRepositoryProvider.overrideWithValue( searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(), FakeSearchHistoryRepository(),
), ),
syncLastErrorProvider.overrideWith( syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
(ref, _) => Stream.value(syncError),
),
]; ];
void main() { void main() {
@@ -122,9 +120,7 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides( overrides: _overrides(
searchResults: [ searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')],
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
), ),
), ),
); );
+46 -47
View File
@@ -430,63 +430,62 @@ void main() {
expect(find.text('Result email'), findsWidgets); expect(find.text('Result email'), findsWidgets);
}); });
testWidgets( testWidgets('deleting all search results pops back to previous screen', (
'deleting all search results pops back to previous screen', tester,
(tester) async { ) async {
final email = testEmail(subject: 'Needle'); final email = testEmail(subject: 'Needle');
// Start at the mailbox list so the email list is pushed on top of it, // Start at the mailbox list so the email list is pushed on top of it,
// making context.canPop() == true inside EmailListScreen. // making context.canPop() == true inside EmailListScreen.
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes', initialLocation: '/accounts/acc-1/mailboxes',
overrides: [ overrides: [
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider.overrideWithValue( mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]), FakeMailboxRepository([kTestMailbox]),
), ),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]), FakeEmailRepository(searchResults: [email]),
), ),
], ],
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(MailboxListScreen), findsOneWidget); expect(find.byType(MailboxListScreen), findsOneWidget);
// Navigate into INBOX (pushes EmailListScreen onto the stack). // Navigate into INBOX (pushes EmailListScreen onto the stack).
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(EmailListScreen), findsOneWidget); expect(find.byType(EmailListScreen), findsOneWidget);
// Search for the email. // Search for the email.
await tester.enterText(find.byType(TextField), 'Needle'); await tester.enterText(find.byType(TextField), 'Needle');
await tester.testTextInput.receiveAction(TextInputAction.search); await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// 'Needle' also appears in the SearchBar input, so match at least one. // 'Needle' also appears in the SearchBar input, so match at least one.
expect(find.text('Needle'), findsAtLeastNWidgets(1)); expect(find.text('Needle'), findsAtLeastNWidgets(1));
// Long-press the sender name (unique to the email tile) to enter // Long-press the sender name (unique to the email tile) to enter
// selection mode. // selection mode.
await tester.longPress(find.text('Bob')); await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.select_all)); await tester.tap(find.byIcon(Icons.select_all));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete)); await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Should have popped back to the mailbox list. // Should have popped back to the mailbox list.
expect(find.byType(EmailListScreen), findsNothing); expect(find.byType(EmailListScreen), findsNothing);
expect(find.byType(MailboxListScreen), findsOneWidget); expect(find.byType(MailboxListScreen), findsOneWidget);
}, });
);
testWidgets( testWidgets(
'deleting some search results updates the list without popping', 'deleting some search results updates the list without popping',
+24 -1
View File
@@ -245,6 +245,10 @@ class FakeEmailRepository implements EmailRepository {
}).toList(); }).toList();
}); });
@override
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread( Stream<List<Email>> observeEmailsInThread(
String accountId, String accountId,
@@ -627,11 +631,13 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
this.menuPosition = MenuPosition.bottom, this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom, this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage, this.afterMailViewAction = AfterMailViewAction.nextMessage,
}); List<String>? trustedImageSenders,
}) : _trustedImageSenders = trustedImageSenders ?? [];
MenuPosition menuPosition; MenuPosition menuPosition;
MenuPosition mailViewButtonPosition; MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction; AfterMailViewAction afterMailViewAction;
final List<String> _trustedImageSenders;
@override @override
Stream<UserPreferences> observePreferences() => Stream.value( Stream<UserPreferences> observePreferences() => Stream.value(
@@ -656,6 +662,23 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async { Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action; afterMailViewAction = action;
} }
@override
Stream<List<String>> observeTrustedImageSenders() =>
Stream.value(List.of(_trustedImageSenders));
@override
Future<void> addTrustedImageSender(String senderEmail) async {
final normalized = senderEmail.toLowerCase();
if (!_trustedImageSenders.contains(normalized)) {
_trustedImageSenders.add(normalized);
}
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
_trustedImageSenders.remove(senderEmail.toLowerCase());
}
} }
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
+2 -6
View File
@@ -89,9 +89,7 @@ void main() {
expect(find.text('No results'), findsOneWidget); expect(find.text('No results'), findsOneWidget);
}); });
testWidgets('shows email results under "Messages" section', ( testWidgets('shows email results under "Messages" section', (tester) async {
tester,
) async {
final email = testEmail(subject: 'Invoice Q3'); final email = testEmail(subject: 'Invoice Q3');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
@@ -122,9 +120,7 @@ void main() {
expect(find.text('Invoice Q3'), findsOneWidget); expect(find.text('Invoice Q3'), findsOneWidget);
}); });
testWidgets('shows folder results under "Folders" section', ( testWidgets('shows folder results under "Folders" section', (tester) async {
tester,
) async {
const archiveMailbox = Mailbox( const archiveMailbox = Mailbox(
id: 'acc-1:Archive', id: 'acc-1:Archive',
accountId: 'acc-1', accountId: 'acc-1',
+15 -19
View File
@@ -20,10 +20,12 @@ Widget _wrap(Widget child) => MaterialApp(
void main() { void main() {
group('buildEmailHtml', () { group('buildEmailHtml', () {
test('forces light color-scheme to prevent black-on-black in dark mode', test(
() { 'forces light color-scheme to prevent black-on-black in dark mode',
_expectLightMode(buildEmailHtml('<p>Hello</p>')); () {
}); _expectLightMode(buildEmailHtml('<p>Hello</p>'));
},
);
test('includes email body content', () { test('includes email body content', () {
final html = buildEmailHtml('<p>Test body</p>'); final html = buildEmailHtml('<p>Test body</p>');
@@ -44,8 +46,9 @@ void main() {
test('prevents horizontal overflow so wide HTML emails are not cut off', test('prevents horizontal overflow so wide HTML emails are not cut off',
() { () {
final html = final html = buildEmailHtml(
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>'); '<table width="600"><tr><td>x</td></tr></table>',
);
// Body clips overflow so fixed-width email tables don't escape the viewport. // Body clips overflow so fixed-width email tables don't escape the viewport.
expect(html, contains('overflow-x: hidden')); expect(html, contains('overflow-x: hidden'));
// Tables are forced to full viewport width so fixed pixel widths don't overflow. // Tables are forced to full viewport width so fixed pixel widths don't overflow.
@@ -62,11 +65,7 @@ void main() {
group('SecureEmailWebView (Linux plain-text fallback)', () { group('SecureEmailWebView (Linux plain-text fallback)', () {
testWidgets('renders extracted text from HTML', (tester) async { testWidgets('renders extracted text from HTML', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(const SecureEmailWebView(htmlBody: '<p>Hello <b>world</b></p>')),
const SecureEmailWebView(
htmlBody: '<p>Hello <b>world</b></p>',
),
),
); );
expect(find.textContaining('Hello'), findsOneWidget); expect(find.textContaining('Hello'), findsOneWidget);
expect(find.textContaining('world'), findsOneWidget); expect(find.textContaining('world'), findsOneWidget);
@@ -92,12 +91,11 @@ void main() {
expect(find.byType(SelectableText), findsOneWidget); expect(find.byType(SelectableText), findsOneWidget);
}); });
testWidgets('toggling loadRemoteImages rebuilds without error', testWidgets('toggling loadRemoteImages rebuilds without error', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(const SecureEmailWebView(htmlBody: '<p>Body</p>')),
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
),
); );
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(
@@ -111,9 +109,7 @@ void main() {
}); });
testWidgets('handles empty HTML body', (tester) async { testWidgets('handles empty HTML body', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(_wrap(const SecureEmailWebView(htmlBody: '')));
_wrap(const SecureEmailWebView(htmlBody: '')),
);
expect(find.byType(SelectableText), findsOneWidget); expect(find.byType(SelectableText), findsOneWidget);
}); });
}); });
+2 -6
View File
@@ -27,13 +27,9 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
sieveRepositoryProvider.overrideWith( sieveRepositoryProvider.overrideWith((ref) => _FakeSieveRepository()),
(ref) => _FakeSieveRepository(),
),
], ],
child: const MaterialApp( child: const MaterialApp(home: SieveScriptsScreen(accountId: 'acc-1')),
home: SieveScriptsScreen(accountId: 'acc-1'),
),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+21 -16
View File
@@ -38,8 +38,9 @@ void main() {
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
timestamp: DateTime.now().subtract(const Duration(hours: 1)), timestamp: DateTime.now().subtract(const Duration(hours: 1)),
); );
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) when(
.thenAnswer((_) async => [staleAction]); mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => [staleAction]);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -48,10 +49,12 @@ void main() {
}, },
); );
testWidgets('shows snackbar for fresh action pushed in current session', testWidgets('shows snackbar for fresh action pushed in current session', (
(tester) async { tester,
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) ) async {
.thenAnswer((_) async => []); when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -64,18 +67,20 @@ void main() {
emailIds: ['e1'], emailIds: ['e1'],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
); );
await ProviderScope.containerOf(context) await ProviderScope.containerOf(
.read(undoServiceProvider.notifier) context,
.pushAction(freshAction); ).read(undoServiceProvider.notifier).pushAction(freshAction);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('1 email(s) moved'), findsOneWidget); expect(find.text('1 email(s) moved'), findsOneWidget);
}); });
testWidgets('shows correct text for delete action (moved to Trash)', testWidgets('shows correct text for delete action (moved to Trash)', (
(tester) async { tester,
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) ) async {
.thenAnswer((_) async => []); when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -88,9 +93,9 @@ void main() {
emailIds: ['e1', 'e2'], emailIds: ['e1', 'e2'],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
); );
await ProviderScope.containerOf(context) await ProviderScope.containerOf(
.read(undoServiceProvider.notifier) context,
.pushAction(deleteAction); ).read(undoServiceProvider.notifier).pushAction(deleteAction);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('2 email(s) moved to Trash'), findsOneWidget); expect(find.text('2 email(s) moved to Trash'), findsOneWidget);
+29 -31
View File
@@ -35,10 +35,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.text('Single mail view button position'), findsOneWidget);
find.text('Single mail view button position'),
findsOneWidget,
);
}); });
testWidgets('menu position bottom option is selected by default', ( testWidgets('menu position bottom option is selected by default', (
@@ -53,8 +50,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>); final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup = final menuGroup = tester.widget<RadioGroup<MenuPosition>>(
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first); radioGroups.first,
);
expect(menuGroup.groupValue, MenuPosition.bottom); expect(menuGroup.groupValue, MenuPosition.bottom);
}); });
@@ -70,8 +68,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>); final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup = final mailViewGroup = tester.widget<RadioGroup<MenuPosition>>(
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last); radioGroups.last,
);
expect(mailViewGroup.groupValue, MenuPosition.bottom); expect(mailViewGroup.groupValue, MenuPosition.bottom);
}); });
@@ -98,27 +97,27 @@ void main() {
}); });
testWidgets( testWidgets(
'tapping Top in mail view button position section updates the repo', ( 'tapping Top in mail view button position section updates the repo',
tester, (tester) async {
) async { await tester.pumpWidget(
await tester.pumpWidget( buildApp(
buildApp( initialLocation: '/accounts/preferences',
initialLocation: '/accounts/preferences', overrides: baseOverrides(),
overrides: baseOverrides(), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last); await tester.tap(find.text('Top').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = ProviderScope.containerOf( final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)), tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider) ).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository; as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top); expect(repo.mailViewButtonPosition, MenuPosition.top);
}); },
);
testWidgets('shows after mail action section', (tester) async { testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -153,14 +152,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>); final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group = final group = tester.widget<RadioGroup<AfterMailViewAction>>(
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first); radioGroups.first,
);
expect(group.groupValue, AfterMailViewAction.nextMessage); expect(group.groupValue, AfterMailViewAction.nextMessage);
}); });
testWidgets('tapping Return to mailbox updates the repo', ( testWidgets('tapping Return to mailbox updates the repo', (tester) async {
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/preferences', initialLocation: '/accounts/preferences',