Compare commits

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

Closes #528

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

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/531
2026-06-07 20:05:57 +02:00
Bot of Thomas Güttler 38f7ada8b5 chore(deps): bump go_router, file_picker, flutter_local_notifications (#532) 2026-06-07 19:45:22 +02:00
Bot of Thomas Güttler a227f8607c fix(ci): use endpoints that exist in Forgejo for wait-time + LAST_DEPLOYED_SHA (#529) 2026-06-07 14:02:01 +02:00
Bot of Thomas Güttler 5db5d957ab fix(ci): use /actions/runs endpoint in remaining wait-time steps (#524) 2026-06-07 06:59:00 +02:00
Bot of Thomas Güttler 0dd1d7232b fix(ci): use /actions/runs endpoint in deploy.yml wait-time steps (#522) 2026-06-07 06:33:57 +02:00
Bot of Thomas Güttler 282a64b4c3 fix: include mailboxPath in IMAP email ID to prevent UID collisions (#511) 2026-06-07 05:30:59 +02:00
Bot of Thomas Güttler 8e26715658 ci: eliminate duplicate build_runner run in CheckGenerated (#514) 2026-06-07 05:30:43 +02:00
Bot of Thomas Güttler e4cc92867e ci(website): add change detection to skip unconditional hourly deploys (#515) 2026-06-07 05:04:58 +02:00
Bot of Thomas Güttler 609208247a ci: parallelize Format/Analyze/CheckGenerated/Coverage in Check() (#513) 2026-06-07 04:38:35 +02:00
Bot of Thomas Güttler 69606ce586 fix: prevent Enter key from re-running a settled search (#479) 2026-06-07 04:38:30 +02:00
Bot of Thomas Güttler 9081b452f3 feat: add structured search with visual filter builder (#469) 2026-06-07 04:38:28 +02:00
Bot of Thomas Güttler b9ccafc709 feat: allow manual entry of glob patterns for trusted image senders (#480) 2026-06-07 04:38:22 +02:00
Bot of Thomas Güttler b1e1ac1de7 fix: remove dual-stack [::]:PORT bind (silences spurious EADDRINUSE errors) (#481) 2026-06-07 04:38:21 +02:00
Bot of Thomas Güttler f22f211e8a docs: update AGENTS.md for new agentloop defaults (merge prompt + label rename) (#471) 2026-06-07 04:38:19 +02:00
25 changed files with 1164 additions and 221 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
+13 -9
View File
@@ -13,23 +13,27 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni
| Label | Trigger | Outcome |
|---|---|---|
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
**State machine:**
```
loop/plan → loop/plan-in-progress → loop/plan-done
↘ NeedSupervisor (on failure)
loop/plan → loop/plan-in-process → loop/plan-done
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-progress → loop/code-done
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-process → loop/merge (via route)
↘ NeedSupervisor (on failure)
loop/merge → loop/merge-in-process → loop/merge-done
↘ NeedSupervisor (on failure)
```
**Rules:**
- 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.
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
- 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.
@@ -39,9 +43,9 @@ loop/code → loop/code-in-progress → loop/code-done
1. Create issue
2. Add label loop/plan → agent writes plan as comment
3. Review plan, request changes or approve
4. Add label loop/code → agent implements + opens PR
5. Review PR, merge
6. Close issue
4. Add label loop/code → agent implements + opens PR + hands off to merge
5. (Optional) Review PR before it merges
6. Merge agent waits for CI and merges the PR automatically
```
## Code conventions
+72
View File
@@ -0,0 +1,72 @@
## Background
The issue is real but the proposed mechanism (predicted-mail table + Message-ID match at next sync) is more complex than needed. Two IMAP facilities cover this directly:
- **RFC 4315 / 6851 — `UIDPLUS` + `MOVE` response code `COPYUID`**: the server returns the destination UID(s) inline with the `MOVE` (or `COPY`+`EXPUNGE`) response. `enough_mail`'s `GenericImapResult.responseCodeCopyUid` already parses this (`/data/home/.pub-cache/hosted/pub.dev/enough_mail-2.1.7/lib/src/imap/response.dart:53`). Stalwart and every modern IMAP server advertise UIDPLUS. We currently throw this away — `client.uidMove(...)` is called for its side effect and the result is ignored (`lib/data/repositories/email_repository_impl.dart:2320,2335,2354`).
- **Fallback — `UID SEARCH HEADER Message-ID …`**: bounded, deterministic, works on every IMAP server. Needed only when `responseCodeCopyUid` is missing.
The undo path already attempts a Message-ID recovery (`lib/core/services/undo_service.dart:78-84`), and `findEmailByMessageId` exists (`lib/data/repositories/email_repository_impl.dart:1899`), so the building blocks are in place; we just need to remap on the write path instead of guessing on the read path.
There is also a latent race: the row is optimistically moved (`mailbox_path = dest`) before the server flush, but the row still carries the source UID. If `_reconcileDeletedImap` runs against the **destination** mailbox between the optimistic write and the flush, it will not find that UID on the server and will hard-delete the row (`lib/data/repositories/email_repository_impl.dart:758-761`).
## Goal
After an IMAP move, the local email row keeps tracking the same physical message under its new UID, without orphaning, without re-fetching, and without breaking UndoLog references.
## Concrete changes
### 1. Capture the new UID inside the flush
`lib/data/repositories/email_repository_impl.dart``_applyPendingChangeImap`, cases `move`, `snooze`, `unsnooze`:
- Capture the `GenericImapResult` returned by `client.uidMove(...)`.
- Read `result.responseCodeCopyUid`. If non-null, zip `sourceSequence``targetSequence` to map `oldUid → newUid`.
- If null (no UIDPLUS), look up the row's `messageId`. If present: `client.selectMailboxByPath(dest)` then `client.uidSearchMessages(searchCriteria: 'HEADER Message-ID "<id>"')` and take the highest UID. If `messageId` is also missing, log a warning and leave the row to be reconciled out on the next pass (worst case: cache miss, body refetch).
- For each old/new pair, call a new helper `_remapEmailAfterMove(row, newUid, destPath)`.
### 2. New helper `_remapEmailAfterMove` (same file)
Composes `newId = "accountId:destPath:newUid"`, then in one Drift transaction:
- `UPDATE email_bodies SET email_id = newId WHERE email_id = oldId`
- `UPDATE emails SET id = newId, uid = newUid, mailbox_path = destPath WHERE id = oldId`
- Patch any `threads.email_ids_json` containing `oldId` (reuse the JSON helper from the v41 migration path, or just rebuild affected threads via `_updateThread`).
- `UPDATE pending_changes SET resource_id = newId WHERE resource_id = oldId` (in case multiple mutations are queued).
- Call `UndoRepository.remapEmailId(oldId, newId)` (new method, see §3).
`PRAGMA defer_foreign_keys = ON` for the transaction, mirroring the v41 migration pattern.
### 3. UndoLog remap
- `lib/data/repositories/undo_repository_impl.dart`: add `Future<void> remapEmailId(String oldId, String newId)` that loads each `undo_actions` row, rewrites `emailIds[]` entries equal to `oldId` and `originalEmails[].id` entries equal to `oldId`, and re-saves.
- `lib/core/repositories/undo_repository.dart`: add the interface method.
- Inject `UndoRepository` into `EmailRepositoryImpl` via DI (already a singleton in `lib/di.dart`) so `_remapEmailAfterMove` can call it.
- The fallback path in `UndoService.undo` (the Message-ID lookup) becomes a defensive backstop and can stay as-is.
### 4. Reconciliation guard
`_reconcileDeletedImap` (`lib/data/repositories/email_repository_impl.dart:731`): before deleting a row whose UID is absent from the server, query `pending_changes` for a `move`/`snooze`/`unsnooze` row with `resource_id = row.id`. If one exists, skip the delete — the row is mid-flight. Since flush always runs before sync (`SYNC.md:199-200`) the race is narrow but real on retries when flush failed mid-cycle.
### 5. Tests
- `test/unit/fake_imap.dart`: make `uidMove` return a `GenericImapResult` whose `responseCode` is a configurable `"COPYUID <validity> <src> <dest>"` string; add a UIDPLUS-off mode that returns no response code and serves `uidSearchMessages` with a HEADER predicate.
- New `test/unit/repositories/email_repository_move_test.dart`:
- Enqueue a move → flush → assert `emails`, `email_bodies`, `threads`, `pending_changes`, `undo_actions` all carry the new id.
- Same with UIDPLUS-off + HEADER-search fallback.
- Move with no Message-ID and no COPYUID → row is unchanged, warning logged, next reconciliation does not delete it because pending change is still queued.
- New test in the same file: `_reconcileDeletedImap` skips a row that has a queued `move`.
- Extend `test/integration/concurrent_sync_test.dart` (or sibling file) with a real Stalwart move-then-sync-then-undo cycle: assert the row keeps its body, the new UID matches what `UID SEARCH` returns, and undo restores it to the source mailbox.
### 6. Docs
- `SYNC.md` §3: note that IMAP moves remap the local id from the `COPYUID` response (UIDPLUS) or `UID SEARCH HEADER Message-ID` fallback, so `email_bodies` and `undo_actions` stay valid across moves.
- `DB-SYNC.md` IMAP bullets: add "IMAP move remap: local row keeps its body/undo references via `COPYUID` (RFC 4315) or Message-ID header search."
## Non-goals
- No new "predicted mail" table — `COPYUID` + header search is reliable and stateless.
- No change to JMAP moves (JMAP IDs are mailbox-independent).
- No change to `uidValidity` handling — orthogonal.
- No CONDSTORE/QRESYNC — already listed as future work in `DB-SYNC.md`.
## Open question for review
Should the fallback header-search be best-effort-quiet (current plan) or should it surface a `FailedMutation` when both `COPYUID` and `Message-ID` are missing? I lean quiet because the next full sync will still pull the message under its new UID — we'd just lose the cached body and any queued mutations on top of it. Happy to escalate to `FailedMutation` if you'd rather make the data loss visible.
+38 -34
View File
@@ -388,7 +388,7 @@ func (m *Ci) Stalwart() *dagger.Service {
return dag.Container().
From("stalwartlabs/stalwart:v0.14.1").
WithFile("/etc/stalwart/config.toml.orig", config).
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
WithDirectory("/tmp/stalwart", dataDir).
WithExposedPort(8080). // JMAP
WithExposedPort(1430). // IMAP
@@ -503,23 +503,19 @@ func (m *Ci) CheckFast(ctx context.Context) (string, error) {
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It snapshots the committed source (including any stale generated files) before
// running build_runner, so git diff detects real staleness instead of always
// comparing two freshly-generated outputs.
// It reuses the codegenBase() output instead of running build_runner a second time,
// diffing committed generated files against the freshly built ones.
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
fresh := m.codegenBase().Directory("/src")
return m.pubGetLayer().
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
WithExec([]string{"git", "init"}).
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
WithExec([]string{"git", "config", "user.name", "CI"}).
WithExec([]string{"git", "add", "."}).
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
`stale=$(find /committed -name '*.g.dart' -o -name '*.mocks.dart' | ` +
`while IFS= read -r f; do rel="${f#/committed/}"; diff -q "$f" "/generated/$rel" >/dev/null 2>&1 || echo "$rel"; done); ` +
`if [ -n "$stale" ]; then ` +
`echo "ERROR: Generated files are out of date — run: dart run build_runner build"; echo "$stale"; exit 1; ` +
`else echo "Generated files are up to date."; fi`}).
Stdout(ctx)
}
@@ -594,25 +590,33 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return "", err
}
checkSetup := m.setup(m.checkSrc())
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
return "Format check failed", err
}
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
if err != nil {
return analyze, err
}
mocks, err := m.CheckGenerated(ctx)
if err != nil {
return mocks, err
}
coverage, err := m.Coverage(ctx)
if err != nil {
return coverage, err
// Run format, analyze, generated-code check, and coverage in parallel —
// they all share the same setup base and have no dependencies on each other.
var analyze, mocks, coverage string
var checkEg errgroup.Group
checkEg.Go(func() error {
setup := m.setup(m.checkSrc())
_, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx)
return err
})
checkEg.Go(func() error {
setup := m.setup(m.checkSrc())
var err error
analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
return err
})
checkEg.Go(func() error {
var err error
mocks, err = m.CheckGenerated(ctx)
return err
})
checkEg.Go(func() error {
var err error
coverage, err = m.Coverage(ctx)
return err
})
if err := checkEg.Wait(); err != nil {
return "", err
}
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 40;
const int dbSchemaVersion = 41;
+2 -8
View File
@@ -1,6 +1,7 @@
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
/// A lightweight email representation used by [SieveInterpreter].
/// Header names are lower-cased.
@@ -102,18 +103,11 @@ class SieveInterpreter {
return switch (matchType) {
':contains' => k.isEmpty || v.contains(k),
':is' => v == k,
':matches' => _globMatch(v, k),
':matches' => globMatch(v, k),
_ => false,
};
}
bool _globMatch(String value, String pattern) {
final regexStr = RegExp.escape(
pattern,
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value);
}
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
for (final action in actions) {
switch (action) {
+9
View File
@@ -0,0 +1,9 @@
/// Returns true if [value] matches the glob [pattern].
///
/// Supports `*` (any number of characters) and `?` (exactly one character).
/// The comparison is case-insensitive, which is appropriate for email addresses.
bool globMatch(String value, String pattern) {
final regexStr =
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value);
}
+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();
+12 -9
View File
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
@@ -73,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),
@@ -132,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'),
@@ -165,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);
@@ -208,8 +211,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final senderEmail = header?.from.isNotEmpty == true
? header!.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
final isTrusted = senderEmail != null &&
trustedSenders.any((p) => globMatch(senderEmail, p));
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return ListView(
+8 -1
View File
@@ -278,7 +278,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
),
],
onChanged: _onSearchChanged,
onSubmitted: _runSearch,
onSubmitted: (value) {
// Only run the search if results haven't settled yet via
// onChanged — prevents a second IMAP round-trip from reordering
// the already-visible results when the user presses Enter.
if (_searchResults == null && !_searchLoading) {
unawaited(_runSearch(value));
}
},
textInputAction: TextInputAction.search,
),
),
+3 -2
View File
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -118,8 +119,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
final senderEmail = widget.email.from.isNotEmpty
? widget.email.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
final isTrusted = senderEmail != null &&
trustedSenders.any((p) => globMatch(senderEmail, p));
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
@@ -16,6 +16,11 @@ class TrustedImageSendersScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Allowed addresses for images')),
floatingActionButton: FloatingActionButton(
tooltip: 'Add address',
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
),
body: trustedSendersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
@@ -26,7 +31,8 @@ class TrustedImageSendersScreen extends ConsumerWidget {
padding: EdgeInsets.all(16),
child: Text(
'No addresses added yet. '
'Tap "Load remote images" in an email to add the sender.',
'Tap + to add an address or pattern (e.g. *@example.com), '
'or tap "Load remote images" in an email to add the sender automatically.',
),
);
}
@@ -60,4 +66,61 @@ class TrustedImageSendersScreen extends ConsumerWidget {
),
);
}
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setState) {
return AlertDialog(
title: const Text('Add allowed address'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address or pattern',
hintText: '*@example.com',
helperText: '* matches any characters, e.g. *@example.com',
),
onChanged: (_) => setState(() {}),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
_addSender(ref, value);
Navigator.of(ctx).pop();
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: controller.text.trim().isEmpty
? null
: () {
_addSender(ref, controller.text);
Navigator.of(ctx).pop();
},
child: const Text('Add'),
),
],
);
},
);
},
);
}
void _addSender(WidgetRef ref, String value) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(value.trim()),
);
}
}
+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 {
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/utils/glob_match.dart';
void main() {
group('globMatch', () {
test('exact match (no wildcards)', () {
expect(globMatch('alice@example.com', 'alice@example.com'), isTrue);
expect(globMatch('alice@example.com', 'bob@example.com'), isFalse);
});
test('* matches any domain wildcard', () {
expect(globMatch('alice@example.com', '*@example.com'), isTrue);
expect(globMatch('bob@example.com', '*@example.com'), isTrue);
expect(globMatch('alice@other.com', '*@example.com'), isFalse);
});
test('* matches zero or more characters', () {
expect(
globMatch('newsletter@news.example.com', '*@*.example.com'),
isTrue,
);
expect(globMatch('alice@example.com', 'alice*'), isTrue);
expect(globMatch('alice@example.com', '*example*'), isTrue);
});
test('? matches exactly one character', () {
expect(globMatch('alice@example.com', 'alice@exampl?.com'), isTrue);
expect(globMatch('alice@example.com', 'alice@exampl??.com'), isFalse);
});
test('case-insensitive comparison', () {
expect(globMatch('Alice@Example.COM', '*@example.com'), isTrue);
expect(globMatch('alice@example.com', '*@EXAMPLE.COM'), isTrue);
});
test('no wildcards — mismatch is false', () {
expect(globMatch('alice@example.com', 'alice@other.com'), isFalse);
});
test('bare * matches everything', () {
expect(globMatch('alice@example.com', '*'), isTrue);
expect(globMatch('', '*'), isTrue);
});
test('empty pattern only matches empty string', () {
expect(globMatch('', ''), isTrue);
expect(globMatch('alice@example.com', ''), isFalse);
});
});
}
+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);
+63 -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,
@@ -798,6 +798,67 @@ void main() {
},
);
testWidgets(
'pressing Enter after search settles does not reorder results',
(tester) async {
// Reproduces: user types a query → onChanged fires → results settle.
// Then user presses Enter → onSubmitted fires a second search → the
// second IMAP response may return results in a different order, so the
// tile the user is about to tap is no longer the email they expect.
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Foo');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Foo');
var callCount = 0;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
callCount++;
// First call: [Alpha, Beta]. Second call: reversed.
return callCount == 1 ? [email1, email2] : [email2, email1];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Typing triggers onChanged → first search → results settle.
await tester.enterText(find.byType(TextField), 'foo');
await tester.pumpAndSettle();
expect(find.text('Alpha Foo'), findsOneWidget);
expect(find.text('Beta Foo'), findsOneWidget);
// Alpha must appear above Beta (it is first in the list).
expect(
tester.getTopLeft(find.text('Alpha Foo')).dy,
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
);
// Pressing Enter triggers onSubmitted — must NOT re-run the search.
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// Order must be unchanged: pressing Enter must not reorder results.
expect(find.text('Alpha Foo'), findsOneWidget);
expect(find.text('Beta Foo'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Alpha Foo')).dy,
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
);
},
);
testWidgets('shows preview snippet when email has preview', (tester) async {
final email = Email(
id: 'acc-1:99',
+10
View File
@@ -44,6 +44,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/search_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
// ---------------------------------------------------------------------------
@@ -484,6 +485,12 @@ Widget buildApp({
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: 'trusted-senders',
builder: (ctx, state) => TrustedImageSendersScreen(
highlightedSender: state.extra as String?,
),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
@@ -696,6 +703,9 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
AfterMailViewAction afterMailViewAction;
final List<String> _trustedImageSenders;
List<String> get trustedImageSendersForTest =>
List.unmodifiable(_trustedImageSenders);
@override
Stream<UserPreferences> observePreferences() => Stream.value(
UserPreferences(
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
void main() {
group('TrustedImageSendersScreen', () {
testWidgets('shows empty state with glob hint when no senders', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('*@example.com'), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
});
testWidgets('lists existing senders', (tester) async {
final repo = FakeUserPreferencesRepository(
trustedImageSenders: ['alice@example.com', '*@work.com'],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
userPreferences: repo,
),
);
await tester.pumpAndSettle();
expect(find.text('alice@example.com'), findsOneWidget);
expect(find.text('*@work.com'), findsOneWidget);
});
testWidgets('add dialog shows glob hint text', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.text('Add allowed address'), findsOneWidget);
expect(find.textContaining('*@example.com'), findsWidgets);
expect(find.textContaining('* matches any characters'), findsOneWidget);
});
testWidgets('Add button is disabled when input is empty', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
final addButton = find.widgetWithText(TextButton, 'Add');
final button = tester.widget<TextButton>(addButton);
expect(button.onPressed, isNull);
});
testWidgets('typing in dialog enables Add button and adds sender', (
tester,
) async {
final repo = FakeUserPreferencesRepository();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
userPreferences: repo,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '*@example.com');
await tester.pumpAndSettle();
final addButton = find.widgetWithText(TextButton, 'Add');
final button = tester.widget<TextButton>(addButton);
expect(button.onPressed, isNotNull);
await tester.tap(addButton);
await tester.pumpAndSettle();
expect(repo.trustedImageSendersForTest, contains('*@example.com'));
});
testWidgets('cancel closes dialog without adding', (tester) async {
final repo = FakeUserPreferencesRepository();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
userPreferences: repo,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'someone@test.com');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(repo.trustedImageSendersForTest, isEmpty);
});
testWidgets('delete button removes a sender', (tester) async {
final repo = FakeUserPreferencesRepository(
trustedImageSenders: ['alice@example.com'],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
userPreferences: repo,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete_outline));
await tester.pumpAndSettle();
expect(repo.trustedImageSendersForTest, isEmpty);
});
testWidgets('lists existing glob patterns', (tester) async {
final repo = FakeUserPreferencesRepository(
trustedImageSenders: ['*@example.com', 'alice@other.com'],
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/trusted-senders',
overrides: baseOverrides(),
userPreferences: repo,
),
);
await tester.pumpAndSettle();
expect(find.text('*@example.com'), findsOneWidget);
expect(find.text('alice@other.com'), findsOneWidget);
});
});
}