Compare commits

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

Closes #528

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

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/531
2026-06-07 20:05:57 +02:00
Bot of Thomas Güttler 38f7ada8b5 chore(deps): bump go_router, file_picker, flutter_local_notifications (#532) 2026-06-07 19:45:22 +02:00
Bot of Thomas Güttler a227f8607c fix(ci): use endpoints that exist in Forgejo for wait-time + LAST_DEPLOYED_SHA (#529) 2026-06-07 14:02:01 +02:00
Bot of Thomas Güttler 5db5d957ab fix(ci): use /actions/runs endpoint in remaining wait-time steps (#524) 2026-06-07 06:59:00 +02:00
Bot of Thomas Güttler 0dd1d7232b fix(ci): use /actions/runs endpoint in deploy.yml wait-time steps (#522) 2026-06-07 06:33:57 +02:00
Bot of Thomas Güttler 282a64b4c3 fix: include mailboxPath in IMAP email ID to prevent UID collisions (#511) 2026-06-07 05:30:59 +02:00
Bot of Thomas Güttler 8e26715658 ci: eliminate duplicate build_runner run in CheckGenerated (#514) 2026-06-07 05:30:43 +02:00
Bot of Thomas Güttler e4cc92867e ci(website): add change detection to skip unconditional hourly deploys (#515) 2026-06-07 05:04:58 +02:00
15 changed files with 737 additions and 164 deletions
+6 -6
View File
@@ -19,14 +19,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+43 -59
View File
@@ -21,14 +21,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -51,43 +51,27 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent workflow run where deploy-playstore actually succeeded
# (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.
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# (per-job records) directly and filter for the task we care about. Filtering at the
# task level also distinguishes runs where the Play Store job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
base_api = f"{server}/api/v1/repos/{repo}/actions"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("status") == "success"
]
# 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
for t in data.get("workflow_runs", []):
if (t.get("workflow_id") == "deploy.yml"
and t.get("name") == "Build & Deploy to Play Store"
and t.get("status") == "success"):
print(t.get("head_sha") or "")
sys.exit(0)
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
@@ -164,14 +148,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -215,14 +199,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -260,14 +244,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -310,14 +294,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+12 -12
View File
@@ -20,14 +20,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -73,14 +73,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+99 -6
View File
@@ -12,10 +12,103 @@ on:
workflow_dispatch:
jobs:
check-changes:
name: Detect Website Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect website changes since last deploy
id: diff
shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: |
# On push or workflow_dispatch always deploy
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent successful "Build & Update Website" task. Forgejo's API
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# (per-job records) directly and filter for the task we care about. Filtering at the
# task level also distinguishes runs where the deploy job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
for t in data.get("workflow_runs", []):
if (t.get("workflow_id") == "website.yml"
and t.get("name") == "Build & Update Website"
and t.get("status") == "success"):
print(t.get("head_sha") or "")
sys.exit(0)
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print("")
PYEOF
)
if [ -z "$LAST_DEPLOYED_SHA" ]; then
echo "::warning::Could not determine last successfully deployed SHA — deploying as a precaution"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::Website deploy SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff from last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit.
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying as a precaution"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changed files:"
echo "$CHANGED"
website_re='^(website/|scripts/website-verify\.sh|\.forgejo/workflows/website\.yml)'
if echo "$CHANGED" | grep -qE "$website_re"; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "::notice::Website deploy TRIGGERED — website-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "::notice::Website deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no website-relevant changes"
fi
deploy:
name: Build & Update Website
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- name: Print runner wait time
@@ -24,14 +117,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+69
View File
@@ -0,0 +1,69 @@
## Background — what already exists in this repo
The Android AAB build-and-upload pipeline is already fully automated. The user does **not** need to drop a file into Play Console at all — the same machinery that publishes to `internal` can publish to the closed-testing track.
- `ci/main.go:952``PublishAndroid()` builds a release AAB, stamps the versionCode (`time.Now().Unix()`), re-signs with the upload key, and uploads to Play Store.
- `ci/main.go:899``UploadToPlayStore()` invokes `scripts/deploy_playstore.py` inside a container.
- `scripts/deploy_playstore.py:14` — pinned to `TRACK = "internal"`. This is the only thing limiting the existing pipeline to internal testing.
- `Taskfile.yml:230``task publish-android` wraps the Dagger call.
- `.forgejo/workflows/deploy.yml``deploy-playstore` job runs every hour (`0 * * * *`) and calls `task publish-android` whenever android-relevant files changed since the last successful deploy.
- `Taskfile.yml:214``task build-android-bundle` builds an **unsigned** AAB locally to `build/app/outputs/bundle/release/app-release.aab`. The signed AAB built by `PublishAndroid` lives only inside Dagger and is **not** currently exported to disk on the runner.
## Recommended path (A): publish to the closed-testing track from CI — no manual upload
This piggybacks on what's already running every hour.
1. **In Play Console**, decide which track maps to "closed testing":
- The built-in `alpha` track is treated as closed testing by the API.
- Or create a custom closed-testing track (e.g. `closed-testers`) under Testing → Closed testing → Create track. Configure the testers list there. Note the track ID exactly as it appears in the URL/console.
2. **Edit `scripts/deploy_playstore.py`** so the upload goes to both internal and the closed track in the same Play edit (one commit, atomic). Concretely:
- Replace `TRACK = "internal"` (line 14) with `TRACKS = ["internal", "alpha"]` (or `["internal", "<custom-track-id>"]`).
- Replace the single `track_resp = session.put(... /tracks/{TRACK} ...)` block (lines 97-101) with a loop that PUTs the same `versionCodes: [version_code]` payload to each track in `TRACKS` before the commit at line 104.
- Internal stays as the smoke-test target; the closed track is what gets the testers.
3. **Update the test fixture** in `scripts/test_deploy_playstore.py` (and any test for `verify_playstore_deploy.py`) to expect the new track list. Run `python3 scripts/test_deploy_playstore.py` locally.
4. **Trigger a deploy**: push the change to `main` and either wait for the next hourly run or `workflow_dispatch` `.forgejo/workflows/deploy.yml` from the Forgejo UI. Note that `deploy.yml:114` already includes `scripts/deploy_playstore\.py` in the change-detection regex, so any normal hourly tick after the merge will pick it up.
5. **Result**: closed-testing release appears in Play Console with the AAB pre-attached. Reviewers only need to edit the release notes (already generated by `task generate-changelog` — see `Taskfile.yml:232`) and click "Roll out". No "Drop app bundles here" step.
Caveat — first time a track is used, Google requires that internal testing has been previously published; that is already true here, so no blocker.
## Alternative path (B): one-shot manual drop — export the signed AAB as a CI artifact
Use this if you want to do this single closed-testing release by hand and decide on full automation later.
1. **Modify `ci/main.go`** to expose the signed AAB as a file return: add a thin wrapper exporting the result of `SignAndroidBundle(StampAndroidVersionCode(BuildAndroidRelease(commitHash), versionCode), ...)` as `*dagger.File` (something like `SignedAndroidBundle(...) *dagger.File`). The existing `PublishAndroid` discards the signed bundle after upload, so a separate entry point is cleaner than reshaping it.
2. **Add a `Taskfile.yml` target** `build-android-bundle-signed` that calls the new Dagger function with `-o build/app/outputs/bundle/release/app-release.aab` (mirroring `build-android-bundle` at `Taskfile.yml:218`).
3. **Add to `.forgejo/workflows/deploy.yml` `deploy-playstore` job** (after `task publish-android`) — or to a new dedicated job — these two steps:
```yaml
- name: Export signed AAB locally
env: { DAGGER_NO_NAG: "1" }
run: task build-android-bundle-signed
- name: Upload AAB artifact
uses: actions/upload-artifact@v4
with:
name: app-release-aab
path: build/app/outputs/bundle/release/app-release.aab
if-no-files-found: error
```
4. **Trigger the workflow** (`workflow_dispatch`), then download `app-release-aab` from the run's artifacts page on Forgejo. Drop it into Play Console.
Risk to flag: the version code stamped via the artifact path must be strictly greater than every version code already on internal. Since the same Unix-timestamp scheme is used everywhere this naturally holds, but if the artifact run and a regular publish run race, the closed release may fail with "Version code N has already been used." Easy fix: only run one or the other.
## Alternative path (C): build locally — fastest one-off
```bash
export ANDROID_KEYSTORE_BASE64=... # same secret used by CI
bash scripts/build_android_bundle_local.sh
```
AAB lands at `build/app/outputs/bundle/release/app-release.aab`. Note this script uses `fvm flutter build appbundle` directly and does **not** match the Dagger pipeline's stamp/sign step exactly — the keystore path is wired via `ANDROID_KEYSTORE_PATH` and the signing config in `android/app/build.gradle.kts` reads from there. Verify the bundle is correctly signed (`jarsigner -verify -verbose -certs`) before uploading.
## Recommendation
Go with path A. The diff is small (~10 lines in `deploy_playstore.py` + test update), reuses the build-and-sign pipeline that has already been proven in `deploy.yml` for months, and removes the "Drop app bundles here" step entirely instead of just satisfying it once.
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 40;
const int dbSchemaVersion = 41;
+110
View File
@@ -679,6 +679,116 @@ class AppDatabase extends _$AppDatabase {
if (from < 40) {
await m.createTable(installedVersions);
}
if (from < 41) {
// Fix IMAP email IDs to include mailboxPath, preventing UID
// collisions across mailboxes (IMAP UIDs are mailbox-scoped).
// New format: "accountId:mailboxPath:uid" (was "accountId:uid").
//
// defer_foreign_keys defers the email_bodies→emails FK check
// to COMMIT so the two tables can be updated sequentially inside
// the migration transaction without a transient FK violation.
await customStatement('PRAGMA defer_foreign_keys = ON');
// 1. Remap email_bodies.email_id before emails.id changes.
await customStatement('''
UPDATE email_bodies
SET email_id = (
SELECT e.account_id || ':' || e.mailbox_path || ':' || CAST(e.uid AS TEXT)
FROM emails e
JOIN accounts a ON a.id = e.account_id
WHERE e.id = email_bodies.email_id
AND a.account_type = 'imap'
)
WHERE EXISTS (
SELECT 1 FROM emails e
JOIN accounts a ON a.id = e.account_id
WHERE e.id = email_bodies.email_id
AND a.account_type = 'imap'
)
''');
// 2. Update emails.thread_id where it was set to the email's own
// id (fallback for messages with no Message-ID header).
await customStatement('''
UPDATE emails
SET thread_id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
AND thread_id = id
''');
// 3. Update the primary key on emails.
await customStatement('''
UPDATE emails
SET id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
WHERE account_id IN (
SELECT id FROM accounts WHERE account_type = 'imap'
)
''');
// 5. Rebuild threads for IMAP accounts from the updated email rows.
// The threads table stores denormalised data (latest_email_id,
// email_ids_json) that references email IDs, so it is simpler to
// delete and reconstruct than to patch the JSON in SQL.
await customStatement('''
DELETE FROM threads
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
''');
final imapAccounts = await (select(accounts)
..where((t) => t.accountType.equals('imap')))
.get();
for (final acct in imapAccounts) {
final emailRows = await (select(emails)
..where((t) => t.accountId.equals(acct.id)))
.get();
final groups = <String, List<Email>>{};
for (final row in emailRows) {
final key = '${row.mailboxPath}:${row.threadId ?? row.id}';
groups.putIfAbsent(key, () => []).add(row);
}
for (final threadEmails in groups.values) {
threadEmails.sort((a, b) {
final da = a.sentAt ?? a.receivedAt;
final db = b.sentAt ?? b.receivedAt;
return da.compareTo(db);
});
final latest = threadEmails.last;
final seen = <String>{};
final participants = <Map<String, dynamic>>[];
for (final e in threadEmails) {
final from = jsonDecode(e.fromJson) as List<dynamic>;
for (final a in from.cast<Map<String, dynamic>>()) {
final email = a['email'] as String;
if (seen.add(email)) {
participants.add({'name': a['name'], 'email': email});
}
}
}
await into(threads).insert(
ThreadsCompanion.insert(
id: latest.threadId ?? latest.id,
accountId: latest.accountId,
mailboxPath: latest.mailboxPath,
subject: Value(latest.subject),
latestDate: latest.sentAt ?? latest.receivedAt,
messageCount: Value(threadEmails.length),
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
participantsJson: Value(jsonEncode(participants)),
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
),
);
}
}
}
},
);
@@ -561,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
for (final msg in result.messages) {
final uid = msg.uid;
if (uid == null) continue;
final emailId = '${account.id}:$uid';
final emailId = '${account.id}:$mailboxPath:$uid';
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
EmailsCompanion(
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
@@ -616,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
continue;
}
bytes += msg.size ?? 0;
final emailId = '${account.id}:$uid';
final emailId = '${account.id}:$mailboxPath:$uid';
final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim();
+9 -7
View File
@@ -74,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !isMobile,
title: Text(
header?.subject ?? '(loading…)',
overflow: TextOverflow.ellipsis,
),
actions: [
IconButton(
icon: const Icon(Icons.reply),
@@ -133,12 +129,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
PopupMenuButton<String>(
itemBuilder: (ctx) => [
const PopupMenuItem(value: 'forward', child: Text('Forward')),
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
@@ -166,8 +170,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
+59 -51
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "99.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "12.1.0"
archive:
dependency: transitive
description:
@@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.1"
code_builder:
dependency: transitive
description:
@@ -237,18 +237,18 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
url: "https://pub.dev"
source: hosted
version: "3.1.7"
version: "3.1.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.14"
device_info_plus:
dependency: "direct main"
description:
@@ -349,10 +349,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
url: "https://pub.dev"
source: hosted
version: "12.0.0-beta.4"
version: "12.0.0-beta.5"
fixnum:
dependency: transitive
description:
@@ -391,34 +391,42 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
url: "https://pub.dev"
source: hosted
version: "21.0.0"
version: "22.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "8.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
url: "https://pub.dev"
source: hosted
version: "11.0.0"
version: "12.0.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.1.0"
flutter_markdown_plus:
dependency: "direct main"
description:
@@ -431,10 +439,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
@@ -447,10 +455,10 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
@@ -526,10 +534,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
url: "https://pub.dev"
source: hosted
version: "17.2.3"
version: "17.3.0"
graphs:
dependency: transitive
description:
@@ -542,10 +550,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.2"
http:
dependency: "direct main"
description:
@@ -675,10 +683,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: "direct main"
description:
@@ -707,10 +715,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.17.6"
version: "0.19.1"
node_preamble:
dependency: transitive
description:
@@ -723,10 +731,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
open_filex:
dependency: "direct main"
description:
@@ -1013,13 +1021,13 @@ packages:
source: hosted
version: "1.10.2"
sqlite3:
dependency: "direct dev"
dependency: "direct main"
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.3.2"
sqlite3_flutter_libs:
dependency: "direct main"
description:
@@ -1088,10 +1096,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
version: "3.4.1"
term_glyph:
dependency: transitive
description:
@@ -1104,26 +1112,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
@@ -1288,10 +1296,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
url: "https://pub.dev"
source: hosted
version: "4.12.0"
version: "4.13.0"
webview_flutter_platform_interface:
dependency: transitive
description:
@@ -1304,10 +1312,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
url: "https://pub.dev"
source: hosted
version: "3.25.1"
version: "3.26.0"
win32:
dependency: transitive
description:
@@ -1381,5 +1389,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+3 -3
View File
@@ -28,7 +28,7 @@ dependencies:
flutter_riverpod: ^3.0.0
# Navigation
go_router: ^17.2.3
go_router: ^17.3.0
# Secure credential storage (passwords)
flutter_secure_storage: ^10.0.0
@@ -37,7 +37,7 @@ dependencies:
intl: ^0.20.2
# File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4
file_picker: ^12.0.0-beta.5
open_filex: ^4.6.0
mime: ^2.0.0
@@ -56,7 +56,7 @@ dependencies:
flutter_markdown_plus: ^1.0.7
# Background sync and local notifications
flutter_local_notifications: ^21.0.0
flutter_local_notifications: ^22.0.0
workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
+130
View File
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/data/db/database.dart' hide Account;
@@ -262,6 +263,50 @@ void main() {
expect(emails.map((e) => e.uid).toList(), [3, 2, 1]);
});
test('same UID in different mailboxes yields independent emails', () async {
// Regression test for the UID collision bug: IMAP UIDs are mailbox-scoped,
// so UID 50 in INBOX and UID 50 in Archive must get distinct local IDs.
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// New ID format: accountId:mailboxPath:uid
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:INBOX:50',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 50,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:Archive:50',
accountId: 'acc-1',
mailboxPath: 'Archive',
uid: 50,
receivedAt: DateTime(2024, 1, 2),
),
);
final inboxEmail = await r.emails.getEmail('acc-1:INBOX:50');
expect(inboxEmail, isNotNull);
expect(inboxEmail!.mailboxPath, 'INBOX');
final archiveEmail = await r.emails.getEmail('acc-1:Archive:50');
expect(archiveEmail, isNotNull);
expect(archiveEmail!.mailboxPath, 'Archive');
final inboxEmails = await r.emails.observeEmails('acc-1', 'INBOX').first;
expect(inboxEmails, hasLength(1));
expect(inboxEmails.first.id, 'acc-1:INBOX:50');
final archiveEmails =
await r.emails.observeEmails('acc-1', 'Archive').first;
expect(archiveEmails, hasLength(1));
expect(archiveEmails.first.id, 'acc-1:Archive:50');
});
test('syncEmails propagates IMAP error', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
@@ -638,6 +683,91 @@ void main() {
expect(results[1].subject, 'Older meeting');
});
test(
'searchEmailsStructured returns results sorted by receivedAt descending',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Older invoice'),
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('Newer invoice'),
receivedAt: DateTime(2024, 6),
),
);
final filter = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
],
);
final results = await r.emails.searchEmailsStructured(null, filter);
expect(results, hasLength(2));
expect(results[0].subject, 'Newer invoice');
expect(results[1].subject, 'Older invoice');
},
);
test(
'getEmailsByAddress returns results sorted by receivedAt descending',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Older hello'),
receivedAt: DateTime(2024),
fromJson: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('Newer hello'),
receivedAt: DateTime(2024, 6),
fromJson: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
final results =
await r.emails.getEmailsByAddress(null, 'bob@example.com');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer hello');
expect(results[1].subject, 'Older hello');
},
);
test(
'searchAddresses returns results sorted by most recently used',
() async {
+179 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 40);
expect(db.schemaVersion, 41);
await db.close();
});
@@ -435,7 +435,184 @@ void main() {
},
);
test('fresh install creates all tables at schemaVersion 40', () async {
test('v40→v41: IMAP email IDs gain mailboxPath segment', () async {
final dbFile = File('test_migration_v40.db');
if (dbFile.existsSync()) dbFile.deleteSync();
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
imap_host TEXT NOT NULL DEFAULT '',
imap_port INTEGER NOT NULL DEFAULT 993,
imap_ssl INTEGER NOT NULL DEFAULT 1,
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_ssl INTEGER NOT NULL DEFAULT 1,
account_type TEXT NOT NULL DEFAULT 'imap',
jmap_url TEXT NULL,
username TEXT NOT NULL DEFAULT '',
verbose INTEGER NOT NULL DEFAULT 0,
manage_sieve_host TEXT NOT NULL DEFAULT '',
manage_sieve_port INTEGER NOT NULL DEFAULT 4190,
manage_sieve_ssl INTEGER NOT NULL DEFAULT 1,
manage_sieve_available INTEGER NULL
)
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
mailbox_path TEXT NOT NULL,
uid INTEGER NOT NULL,
subject TEXT NULL,
sent_at INTEGER NULL,
received_at INTEGER NOT NULL,
from_json TEXT NOT NULL DEFAULT '[]',
to_addresses TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
is_seen INTEGER NOT NULL DEFAULT 0,
is_flagged INTEGER NOT NULL DEFAULT 0,
has_attachment INTEGER NOT NULL DEFAULT 0,
thread_id TEXT NULL,
message_id TEXT NULL,
in_reply_to TEXT NULL,
"references" TEXT NULL,
snoozed_until INTEGER NULL,
snoozed_from_mailbox_path TEXT NULL,
list_unsubscribe_header TEXT NULL
)
''');
rawDb.execute('''
CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails (id) ON DELETE CASCADE,
text_body TEXT NULL,
html_body TEXT NULL,
attachments_json TEXT NOT NULL DEFAULT '[]',
cached_at INTEGER NULL,
headers_json TEXT NULL,
mime_tree_json TEXT NULL
)
''');
rawDb.execute('''
CREATE TABLE threads (
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
mailbox_path TEXT NOT NULL,
id TEXT NOT NULL,
subject TEXT NULL,
latest_date INTEGER NOT NULL,
message_count INTEGER NOT NULL DEFAULT 1,
has_unread INTEGER NOT NULL DEFAULT 0,
is_flagged INTEGER NOT NULL DEFAULT 0,
participants_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
latest_email_id TEXT NOT NULL,
email_ids_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (account_id, mailbox_path, id)
)
''');
rawDb.execute('''
CREATE TABLE pending_changes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
change_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NULL
)
''');
// Insert an IMAP account.
rawDb.execute(
"INSERT INTO accounts (id, display_name, email) VALUES ('acc-1', 'Alice', 'alice@example.com')",
);
// Two emails with the same UID but in different mailboxes — old format.
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
rawDb.execute(
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
"VALUES ('acc-1:50', 'acc-1', 'INBOX', 50, $now, 'acc-1:50')",
);
rawDb.execute(
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at) '
"VALUES ('acc-1:50-arch', 'acc-1', 'Archive', 50, $now)",
);
// A third email with a Message-ID-based thread_id (should not be changed).
rawDb.execute(
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
"VALUES ('acc-1:99', 'acc-1', 'INBOX', 99, $now, '<original@example.com>')",
);
// Email body for the first email.
rawDb.execute(
"INSERT INTO email_bodies (email_id, text_body) VALUES ('acc-1:50', 'body text')",
);
// Thread for the first email (old-format IDs).
rawDb.execute(
'INSERT INTO threads (account_id, mailbox_path, id, latest_date, latest_email_id, email_ids_json) '
"VALUES ('acc-1', 'INBOX', 'acc-1:50', $now, 'acc-1:50', '[\"acc-1:50\"]')",
);
// A pending change referencing the first email's old ID.
rawDb.execute(
'INSERT INTO pending_changes (account_id, resource_type, resource_id, change_type, payload, created_at) '
"VALUES ('acc-1', 'Email', 'acc-1:50', 'flag_seen', '{\"seen\":true}', $now)",
);
rawDb.execute('PRAGMA user_version = 40');
rawDb.close();
// Open with Drift to trigger the migration.
final db = AppDatabase(NativeDatabase(dbFile));
await db.select(db.accounts).get();
// emails.id should now use the accountId:mailboxPath:uid format.
final emailRows = await db.select(db.emails).get();
final emailIds = emailRows.map((r) => r.id).toSet();
expect(emailIds, contains('acc-1:INBOX:50'));
expect(emailIds, contains('acc-1:Archive:50'));
expect(emailIds, contains('acc-1:INBOX:99'));
// Old-format IDs must be gone.
expect(emailIds, isNot(contains('acc-1:50')));
expect(emailIds, isNot(contains('acc-1:99')));
// email_bodies.email_id must be updated.
final bodyRows = await db.select(db.emailBodies).get();
expect(bodyRows, hasLength(1));
expect(bodyRows.first.emailId, 'acc-1:INBOX:50');
// thread_id where it was the email's own ID should be updated.
final inboxEmail = emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:50');
expect(inboxEmail.threadId, 'acc-1:INBOX:50');
// thread_id based on a real Message-ID must be left unchanged.
final inboxEmail99 =
emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:99');
expect(inboxEmail99.threadId, '<original@example.com>');
// threads must be rebuilt with new-format IDs.
final threadRows = await db.select(db.threads).get();
final thread = threadRows.firstWhere((t) => t.mailboxPath == 'INBOX');
expect(thread.latestEmailId, 'acc-1:INBOX:50');
expect(thread.emailIdsJson, contains('acc-1:INBOX:50'));
// pending_changes.resource_id is not updated by the migration
// (IMAP operations use payload uid/mailboxPath, so this is safe).
final changeRows = await db.select(db.pendingChanges).get();
expect(changeRows, hasLength(1));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 41', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
+13 -13
View File
@@ -81,7 +81,7 @@ void main() {
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows subject in app bar after data loads', (tester) async {
testWidgets('shows subject in email header section', (tester) async {
final email = testEmail(subject: 'Project update');
const body = EmailBody(
emailId: 'acc-1:42',
@@ -106,8 +106,8 @@ void main() {
);
await tester.pumpAndSettle();
// Subject appears in both the app bar and the email header section.
expect(find.text('Project update'), findsAtLeastNWidgets(1));
// Subject appears only in the email header section, not in the app bar.
expect(find.text('Project update'), findsOneWidget);
expect(find.text('See attached slides.'), findsOneWidget);
});
@@ -266,7 +266,7 @@ void main() {
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 a standalone button, not in popup menu', (
tester,
) async {
await tester.pumpWidget(
@@ -279,19 +279,19 @@ void main() {
);
await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
// Standalone icon button for mark as spam is in the app bar.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsNothing,
findsOneWidget,
);
// It appears in the popup menu.
// It does NOT appear in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
expect(find.text('Mark as spam'), findsNothing);
});
testWidgets('Mark as spam shows dialog when no junk folder', (
@@ -309,11 +309,11 @@ void main() {
);
await tester.pumpAndSettle();
// Open the popup menu first, then tap Mark as spam.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as spam'));
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
);
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
+2 -2
View File
@@ -446,10 +446,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
// The detail body header shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
of: find.byType(EmailDetailScreen),
matching: find.text('Alpha Match'),
),
findsOneWidget,