Compare commits

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

Closes #528

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

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/531
2026-06-07 20:05:57 +02:00
Bot of Thomas Güttler 38f7ada8b5 chore(deps): bump go_router, file_picker, flutter_local_notifications (#532) 2026-06-07 19:45:22 +02:00
Bot of Thomas Güttler a227f8607c fix(ci): use endpoints that exist in Forgejo for wait-time + LAST_DEPLOYED_SHA (#529) 2026-06-07 14:02:01 +02:00
Bot of Thomas Güttler 5db5d957ab fix(ci): use /actions/runs endpoint in remaining wait-time steps (#524) 2026-06-07 06:59:00 +02:00
Bot of Thomas Güttler 0dd1d7232b fix(ci): use /actions/runs endpoint in deploy.yml wait-time steps (#522) 2026-06-07 06:33:57 +02:00
Bot of Thomas Güttler 282a64b4c3 fix: include mailboxPath in IMAP email ID to prevent UID collisions (#511) 2026-06-07 05:30:59 +02:00
Bot of Thomas Güttler 8e26715658 ci: eliminate duplicate build_runner run in CheckGenerated (#514) 2026-06-07 05:30:43 +02:00
Bot of Thomas Güttler e4cc92867e ci(website): add change detection to skip unconditional hourly deploys (#515) 2026-06-07 05:04:58 +02:00
Bot of Thomas Güttler 609208247a ci: parallelize Format/Analyze/CheckGenerated/Coverage in Check() (#513) 2026-06-07 04:38:35 +02:00
Bot of Thomas Güttler 69606ce586 fix: prevent Enter key from re-running a settled search (#479) 2026-06-07 04:38:30 +02:00
Bot of Thomas Güttler 9081b452f3 feat: add structured search with visual filter builder (#469) 2026-06-07 04:38:28 +02:00
Bot of Thomas Güttler b9ccafc709 feat: allow manual entry of glob patterns for trusted image senders (#480) 2026-06-07 04:38:22 +02:00
Bot of Thomas Güttler b1e1ac1de7 fix: remove dual-stack [::]:PORT bind (silences spurious EADDRINUSE errors) (#481) 2026-06-07 04:38:21 +02:00
Bot of Thomas Güttler f22f211e8a docs: update AGENTS.md for new agentloop defaults (merge prompt + label rename) (#471) 2026-06-07 04:38:19 +02:00
Bot of Thomas Güttler 76f2635700 fix(search): sort search results by received date descending (#520) 2026-06-07 04:24:24 +02:00
Bot of Thomas Güttler e2bb299300 fix(ci): exclude chaos_monkey_test from regular CI (#518) 2026-06-07 04:24:10 +02:00
Bot of Thomas Güttler f5abe9132b fix(test): sync before searching in second searchEmails IMAP test (#519) 2026-06-07 02:49:53 +02:00
44 changed files with 2972 additions and 338 deletions
+6 -13
View File
@@ -19,21 +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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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 -94
View File
@@ -21,21 +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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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
@@ -58,43 +51,27 @@ for r in data.get('workflow_runs', []):
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})")
@@ -171,21 +148,14 @@ for r in data.get('workflow_runs', []):
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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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
@@ -229,21 +199,14 @@ for r in data.get('workflow_runs', []):
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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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
@@ -281,21 +244,14 @@ for r in data.get('workflow_runs', []):
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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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
@@ -338,21 +294,14 @@ for r in data.get('workflow_runs', []):
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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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 -26
View File
@@ -20,21 +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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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
@@ -80,21 +73,14 @@ for r in data.get('workflow_runs', []):
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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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 -13
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,21 +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)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
"${{ 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
+42
View File
@@ -0,0 +1,42 @@
## Goal
Make each email row in **Undo Log Detail** (`lib/ui/screens/undo_log_detail_screen.dart`) tappable, navigating to that email's current location in the app. The issue gates this on "when structured search is implemented" — structured search now exists (`lib/core/filter/filter_expression.dart`, `lib/ui/screens/search_screen.dart`), and the repository already exposes `findEmailByMessageId(accountId, messageId)` (`lib/data/repositories/email_repository_impl.dart:1899`), so we have the building block needed to locate an email regardless of moves.
## Why direct lookup, not a search-screen handoff
After a Move, the email lives at `destinationMailboxPath` with a *new* UID, so the `Email.id` stored in `UndoAction.originalEmails` is stale. The stable identifier across moves is `messageId`. `findEmailByMessageId` returns the current row (with current `mailboxPath` and `id`), giving us a one-tap deep link to the email detail screen — better UX than dumping the user into search.
## Implementation
### 1. `lib/ui/screens/undo_log_detail_screen.dart`
- Convert `_EmailTile` from `StatelessWidget` to `ConsumerWidget` so it can read `emailRepositoryProvider`.
- Take `accountId` as an additional ctor arg (passed in from the `originalEmails.map` call site so we don't depend on the email's own field, matching how the action scopes lookups).
- Add an `onTap` handler:
1. If `email.messageId == null` → no-op tap, show `SnackBar('Cannot locate this email — no Message-ID.')`.
2. Otherwise call `ref.read(emailRepositoryProvider).findEmailByMessageId(action.accountId, email.messageId!)`.
3. On hit, `context.go('/accounts/${accountId}/mailboxes/${Uri.encodeComponent(found.mailboxPath)}/emails/${Uri.encodeComponent(found.id)}')` — matches the encoding pattern used in `combined_inbox_screen.dart:280` and `email_list_screen.dart:540`.
4. On miss, show `SnackBar('Email no longer exists at its previous location. Use Undo to restore it.')` — covers the hard-deleted case and the not-yet-resynced case.
- Add `trailing: const Icon(Icons.chevron_right)` to give the row a "navigates" affordance consistent with other tappable `ListTile`s in the app.
- Leave styling otherwise unchanged; the existing `Icons.email_outlined` leading + subject/sender layout stays.
### 2. No router changes
The existing email-detail route (`router.dart:153`) is reused as-is.
### 3. No model / repository changes
`findEmailByMessageId` is already on `EmailRepository` and scoped per account, which is what we want.
### 4. Tests — `test/widget/`
Add a new widget test `test/widget/undo_log_detail_screen_test.dart` covering:
- Tapping a row whose `messageId` resolves via a fake `EmailRepository` navigates to `/accounts/<acc>/mailboxes/<encoded-path>/emails/<encoded-id>` (assert via a `GoRouter` test harness similar to `test/widget/email_detail_screen_test.dart`).
- Tapping a row when `findEmailByMessageId` returns `null` shows the "no longer exists" SnackBar and does not navigate.
- Tapping a row with `messageId == null` shows the "no Message-ID" SnackBar.
## Out of scope
- Adding Message-ID as a structured `FilterField` — not needed for direct navigation; can be revisited if a UI for "search for this email" is ever wanted.
- Changing the Undo Log list screen (`undo_log_screen.dart`) — the issue is specifically about the *detail* screen.
- Persisting/refreshing a stale `originalEmails` list — Move/Snooze update the row in place, so subsequent re-lookups by Message-ID will find them; nothing to maintain.
+40 -36
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)
}
@@ -539,7 +535,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
@@ -570,7 +566,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
@@ -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;
+88
View File
@@ -0,0 +1,88 @@
enum FilterField {
from_,
to,
cc,
subject,
size;
String get label => switch (this) {
FilterField.from_ => 'From',
FilterField.to => 'To',
FilterField.cc => 'CC',
FilterField.subject => 'Subject',
FilterField.size => 'Size (bytes)',
};
List<FilterComparison> get allowedComparisons => switch (this) {
FilterField.size => [FilterComparison.over, FilterComparison.under],
_ => [
FilterComparison.contains,
FilterComparison.is_,
FilterComparison.matches,
],
};
}
enum FilterComparison {
contains,
is_,
matches,
over,
under;
String get label => switch (this) {
FilterComparison.contains => 'contains',
FilterComparison.is_ => 'is',
FilterComparison.matches => 'matches',
FilterComparison.over => 'over',
FilterComparison.under => 'under',
};
}
enum FilterOperator { and_, or_ }
sealed class FilterNode {}
final class FilterLeaf extends FilterNode {
FilterLeaf({
required this.field,
required this.comparison,
required this.value,
});
final FilterField field;
final FilterComparison comparison;
final String value;
FilterLeaf copyWith({
FilterField? field,
FilterComparison? comparison,
String? value,
}) =>
FilterLeaf(
field: field ?? this.field,
comparison: comparison ?? this.comparison,
value: value ?? this.value,
);
}
final class FilterGroup extends FilterNode {
FilterGroup({required this.operator, required this.children});
final FilterOperator operator;
final List<FilterNode> children;
bool get isEmpty => children.isEmpty;
FilterGroup copyWith({
FilterOperator? operator,
List<FilterNode>? children,
}) =>
FilterGroup(
operator: operator ?? this.operator,
children: children ?? this.children,
);
static FilterGroup empty() =>
FilterGroup(operator: FilterOperator.and_, children: []);
}
+358
View File
@@ -0,0 +1,358 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
/// suitable for display in the visual filter editor.
///
/// Returns null if the script uses features outside the supported subset.
class FilterSieveConverter {
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
try {
final s = _Sc(script);
s.skip();
if (s.peekWord() == 'require') {
s.readWord();
s.skip();
_parseStringOrList(s);
s.skip();
s.expectChar(';');
s.skip();
}
if (s.peekWord() != 'if') return null;
s.readWord();
s.skip();
final node = _parseTest(s);
if (node == null) return null;
s.skip();
s.expectChar('{');
s.skip();
final actions = <SieveAction>[];
while (s.peek() != '}' && !s.isAtEnd) {
final action = _parseAction(s);
if (action == null) return null;
actions.add(action);
s.skip();
}
s.expectChar('}');
final group = switch (node) {
final FilterGroup g => g,
final FilterLeaf l =>
FilterGroup(operator: FilterOperator.and_, children: [l]),
};
return (group: group, actions: actions);
} catch (_) {
return null;
}
}
FilterNode? _parseTest(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'allof' || word == 'anyof') {
s.readWord();
s.skip();
s.expectChar('(');
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
final children = <FilterNode>[];
while (true) {
s.skip();
if (s.peek() == ')') break;
final child = _parseTest(s);
if (child == null) return null;
children.add(child);
s.skip();
if (s.peek() == ',') s.advance();
}
s.expectChar(')');
return FilterGroup(operator: op, children: children);
}
return _parseSingleTest(s);
}
FilterLeaf? _parseSingleTest(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'address') {
s.readWord();
s.skip();
final matchType = s.readTaggedArg();
s.skip();
final headers = _parseStringOrList(s);
s.skip();
final values = _parseStringOrList(s);
final field = switch (headers.firstOrNull?.toLowerCase()) {
'from' => FilterField.from_,
'to' => FilterField.to,
'cc' => FilterField.cc,
_ => null,
};
if (field == null) return null;
final comp = _comp(matchType);
if (comp == null) return null;
return FilterLeaf(
field: field,
comparison: comp,
value: values.firstOrNull ?? '',
);
}
if (word == 'header') {
s.readWord();
s.skip();
final matchType = s.readTaggedArg();
s.skip();
final headers = _parseStringOrList(s);
s.skip();
final values = _parseStringOrList(s);
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
final comp = _comp(matchType);
if (comp == null) return null;
return FilterLeaf(
field: FilterField.subject,
comparison: comp,
value: values.firstOrNull ?? '',
);
}
if (word == 'size') {
s.readWord();
s.skip();
final compTag = s.readTaggedArg();
s.skip();
final numStr = s.readDigits();
final comp = switch (compTag.toLowerCase()) {
':over' => FilterComparison.over,
':under' => FilterComparison.under,
_ => null,
};
if (comp == null) return null;
return FilterLeaf(
field: FilterField.size,
comparison: comp,
value: numStr,
);
}
return null;
}
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
':contains' => FilterComparison.contains,
':is' => FilterComparison.is_,
':matches' => FilterComparison.matches,
_ => null,
};
SieveAction? _parseAction(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'fileinto') {
s.readWord();
s.skip();
final folder = _parseString(s);
s.skip();
s.expectChar(';');
return FileIntoAction(folder);
}
if (word == 'keep') {
s.readWord();
s.skip();
s.expectChar(';');
return KeepAction();
}
if (word == 'discard') {
s.readWord();
s.skip();
s.expectChar(';');
return DiscardAction();
}
if (word == 'setflag' || word == 'addflag') {
s.readWord();
s.skip();
final flags = _parseStringOrList(s);
s.skip();
s.expectChar(';');
if (flags.any(
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
)) {
return MarkAsSeenAction();
}
return FlagAction(flags);
}
return null;
}
List<String> _parseStringOrList(_Sc s) {
s.skip();
if (s.peek() == '[') {
s.advance();
final items = <String>[];
while (true) {
s.skip();
if (s.peek() == ']') {
s.advance();
break;
}
items.add(_parseString(s));
s.skip();
if (s.peek() == ',') s.advance();
}
return items;
}
return [_parseString(s)];
}
String _parseString(_Sc s) {
s.skip();
return s.readQuotedString();
}
}
// Minimal scanner for the supported Sieve subset.
class _Sc {
_Sc(this._src);
final String _src;
int _pos = 0;
bool get isAtEnd => _pos >= _src.length;
String? peek() => isAtEnd ? null : _src[_pos];
String advance() {
if (isAtEnd) throw _ScanErr('Unexpected end');
return _src[_pos++];
}
void skip() {
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
_pos++;
} else if (ch == '#') {
while (!isAtEnd && _src[_pos] != '\n') {
_pos++;
}
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
_pos += 2;
while (_pos + 1 < _src.length) {
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
_pos += 2;
break;
}
_pos++;
}
} else {
break;
}
}
}
String? peekWord() {
if (isAtEnd) return null;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) return ch;
if (ch == ':') {
var end = _pos + 1;
while (end < _src.length && _wc(_src[end])) {
end++;
}
return _src.substring(_pos, end).toLowerCase();
}
if (_wc(ch)) {
var end = _pos + 1;
while (end < _src.length && _wc(_src[end])) {
end++;
}
return _src.substring(_pos, end).toLowerCase();
}
return null;
}
String readWord() {
final start = _pos;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) {
_pos++;
return ch;
}
if (ch == ':') {
_pos++;
while (!isAtEnd && _wc(_src[_pos])) {
_pos++;
}
} else {
while (!isAtEnd && _wc(_src[_pos])) {
_pos++;
}
}
return _src.substring(start, _pos).toLowerCase();
}
String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord();
throw _ScanErr('Expected tagged arg at $_pos');
}
String readDigits() {
final start = _pos;
while (!isAtEnd && _dig(_src[_pos])) {
_pos++;
}
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
return _src.substring(start, _pos);
}
String readQuotedString() {
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
_pos++;
final buf = StringBuffer();
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == '"') {
_pos++;
return buf.toString();
}
if (ch == '\\' && _pos + 1 < _src.length) {
_pos++;
buf.write(_src[_pos]);
_pos++;
} else {
buf.write(ch);
_pos++;
}
}
throw _ScanErr('Unterminated string');
}
void expectChar(String ch) {
skip();
if (isAtEnd || _src[_pos] != ch) {
throw _ScanErr(
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
);
}
_pos++;
}
static bool _wc(String ch) {
final c = ch.codeUnitAt(0);
return (c >= 0x41 && c <= 0x5A) ||
(c >= 0x61 && c <= 0x7A) ||
(c >= 0x30 && c <= 0x39) ||
c == 0x5F ||
c == 0x2D;
}
static bool _dig(String ch) {
final c = ch.codeUnitAt(0);
return c >= 0x30 && c <= 0x39;
}
}
class _ScanErr implements Exception {
_ScanErr(this.message);
final String message;
}
@@ -1,3 +1,4 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository {
@@ -61,6 +62,12 @@ abstract class EmailRepository {
/// if null) by subject, preview, and notes. Fast, works offline.
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
);
/// Returns all locally cached emails in any mailbox of [accountId] (or all
/// accounts if null) whose from, to, or cc fields contain [address].
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
+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) {
+100
View File
@@ -0,0 +1,100 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
/// (RFC 5228 subset).
class SieveSerializer {
String serialize(FilterGroup filter, List<SieveAction> actions) {
final buf = StringBuffer();
final requires = _collectRequires(actions);
if (requires.isNotEmpty) {
buf.writeln(
'require [${requires.map((r) => '"$r"').join(', ')}];',
);
}
if (filter.isEmpty) {
for (final a in actions) {
buf.writeln(_serializeAction(a));
}
return buf.toString();
}
buf.write('if ');
buf.write(_serializeNode(filter));
buf.writeln(' {');
for (final a in actions) {
buf.writeln(' ${_serializeAction(a)}');
}
buf.writeln('}');
return buf.toString();
}
List<String> _collectRequires(List<SieveAction> actions) {
final req = <String>[];
for (final a in actions) {
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
if ((a is FlagAction || a is MarkAsSeenAction) &&
!req.contains('imap4flags')) {
req.add('imap4flags');
}
}
return req;
}
String _serializeNode(FilterNode node) => switch (node) {
final FilterLeaf leaf => _serializeLeaf(leaf),
final FilterGroup group => _serializeGroup(group),
};
String _serializeGroup(FilterGroup group) {
if (group.isEmpty) return 'true';
if (group.children.length == 1) return _serializeNode(group.children.first);
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
final parts = group.children.map(_serializeNode).join(',\n ');
return '$op(\n $parts\n)';
}
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
FilterField.from_ ||
FilterField.to ||
FilterField.cc =>
_serializeAddressLeaf(leaf),
FilterField.subject => _serializeHeaderLeaf(leaf),
FilterField.size => _serializeSizeLeaf(leaf),
};
String _serializeAddressLeaf(FilterLeaf leaf) {
final header = switch (leaf.field) {
FilterField.from_ => 'from',
FilterField.to => 'to',
FilterField.cc => 'cc',
_ => throw StateError('not an address field'),
};
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
}
String _serializeHeaderLeaf(FilterLeaf leaf) =>
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
String _serializeSizeLeaf(FilterLeaf leaf) {
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
return 'size $comp ${leaf.value}';
}
String _matchType(FilterComparison comp) => switch (comp) {
FilterComparison.contains => ':contains',
FilterComparison.is_ => ':is',
FilterComparison.matches => ':matches',
_ => ':contains',
};
String _serializeAction(SieveAction action) => switch (action) {
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
KeepAction() => 'keep;',
DiscardAction() => 'discard;',
MarkAsSeenAction() => r'setflag "\\Seen";',
final FlagAction a =>
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
};
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
}
+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()),
),
),
);
}
}
}
},
);
+105 -25
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/email.dart' as model;
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -560,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),
@@ -615,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();
@@ -2922,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
final sql = accountId != null
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
@@ -2942,6 +2943,7 @@ class EmailRepositoryImpl implements EmailRepository {
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
if (seen.add(e.id)) merged.add(e);
}
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
return merged;
}
@@ -2952,16 +2954,12 @@ class EmailRepositoryImpl implements EmailRepository {
String? mailboxPath,
String query,
) async {
final words = query
.trim()
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.toList();
final words =
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return [];
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
final likeVars =
words.map((w) => Variable<String>('%$w%')).toList();
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
final extraConditions = StringBuffer();
final extraVars = <Variable<String>>[];
@@ -2980,26 +2978,108 @@ class EmailRepositoryImpl implements EmailRepository {
' WHERE $noteConditions$extraConditions'
' ORDER BY e.received_at DESC LIMIT 50';
final rows = await _db
.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
)
.get();
final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
final rows = await _db.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
).get();
final emailRows =
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
return emailRows.map(_toModel).toList();
}
@override
Future<List<model.Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async {
final rows = await (_db.select(_db.emails)
..where((t) {
final fe = _filterGroup(filter, t);
if (accountId == null) return fe;
return t.accountId.equals(accountId) & fe;
})
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(100))
.get();
return rows.map(_toModel).toList();
}
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
if (group.isEmpty) return const Constant(true);
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
return switch (group.operator) {
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
};
}
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
switch (node) {
final FilterLeaf l => _filterLeaf(l, t),
final FilterGroup g => _filterGroup(g, t),
};
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
final val = leaf.value.toLowerCase();
return switch (leaf.field) {
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
// Size is not stored in the local cache; skip silently.
FilterField.size => const Constant(true),
};
}
Expression<bool> _jsonLike(
GeneratedColumn<String> col,
FilterComparison comp,
String val,
) =>
switch (comp) {
FilterComparison.contains => col.like('%$val%'),
FilterComparison.is_ => col.like('%"email":"$val"%'),
FilterComparison.matches => col.like(_globToLike(val)),
_ => const Constant(true),
};
Expression<bool> _textLike(
GeneratedColumn<String> col,
FilterComparison comp,
String val,
) =>
switch (comp) {
FilterComparison.contains => col.like('%$val%'),
FilterComparison.is_ => col.like(val),
FilterComparison.matches => col.like(_globToLike(val)),
_ => const Constant(true),
};
static String _globToLike(String glob) {
final buf = StringBuffer();
for (var i = 0; i < glob.length; i++) {
final ch = glob[i];
if (ch == '%' || ch == '_') {
buf.write('\\$ch');
} else if (ch == '*') {
buf.write('%');
} else if (ch == '?') {
buf.write('_');
} else {
buf.write(ch);
}
}
return buf.toString();
}
/// Converts a user query string into an FTS5 match expression.
/// Each whitespace-separated word becomes a prefix term (word*) so that
/// partial words still match. Special FTS5 characters are stripped.
static String _toFtsQuery(String query) {
final words = query
.trim()
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.split(RegExp(r'[^\w]+'))
.where((w) => w.isNotEmpty)
.toList();
if (words.isEmpty) return '';
@@ -3113,7 +3193,7 @@ class EmailRepositoryImpl implements EmailRepository {
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
' ORDER BY rank LIMIT 50';
' ORDER BY e.received_at DESC LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
@@ -3126,14 +3206,14 @@ class EmailRepositoryImpl implements EmailRepository {
queryRows.map((r) => _db.emails.mapFromRow(r)),
);
final noteRows =
await _searchEmailsByNotes(accountId, mailboxPath, query);
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
final seen = <String>{};
final merged = <model.Email>[];
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
if (seen.add(e.id)) merged.add(e);
}
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
return merged;
}
+2
View File
@@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -116,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
routerConfig: router,
);
+1
View File
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: Scaffold(
appBar: AppBar(
title: const Text('Something went wrong'),
+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,
),
),
+106 -11
View File
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
@@ -37,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
bool _loading = false;
bool _fieldFocused = false;
// Advanced (structured) search state.
bool _advancedMode = false;
FilterGroup _filterGroup = FilterGroup.empty();
@override
void initState() {
super.initState();
@@ -53,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
super.dispose();
}
void _toggleAdvanced() {
setState(() {
_advancedMode = !_advancedMode;
_results = null;
});
}
void _onChanged(String value) {
_debounce?.cancel();
if (value.trim().length < 3) {
@@ -135,22 +148,47 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
}
Future<void> _searchStructured() async {
if (_filterGroup.isEmpty) return;
setState(() => _loading = true);
try {
final emails = await ref
.read(emailRepositoryProvider)
.searchEmailsStructured(widget.accountId, _filterGroup);
if (mounted) {
setState(() {
_results = _SearchResults(
mailboxes: const [],
addresses: const [],
emails: emails,
);
_loading = false;
});
}
} catch (e) {
log('Structured search failed: $e');
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
title: _advancedMode
? const Text('Advanced Search')
: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
actions: [
if (_ctrl.text.isNotEmpty)
if (!_advancedMode && _ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
@@ -158,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
setState(() => _results = null);
},
),
IconButton(
icon: Icon(
_advancedMode ? Icons.search : Icons.tune,
color:
_advancedMode ? Theme.of(context).colorScheme.primary : null,
),
tooltip: _advancedMode ? 'Simple search' : 'Advanced search',
onPressed: _toggleAdvanced,
),
],
),
body: _buildBody(),
@@ -165,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
Widget _buildBody() {
if (_advancedMode) return _buildAdvancedBody();
if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) {
if (_fieldFocused && _ctrl.text.isEmpty) {
@@ -174,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
final r = _results!;
if (r.isEmpty) return const Center(child: Text('No results'));
return _buildResultsList(r);
}
Widget _buildAdvancedBody() {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilterBuilderWidget(
initialValue: _filterGroup,
onChanged: (g) => setState(() {
_filterGroup = g;
_results = null;
}),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _filterGroup.isEmpty ? null : _searchStructured,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
if (_loading)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_results != null) ...[
const SizedBox(height: 8),
if (_results!.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No results'),
),
)
else
_buildResultsList(_results!),
],
],
),
);
}
Widget _buildResultsList(_SearchResults r) {
return ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
if (r.mailboxes.isNotEmpty) ...[
const _SectionHeader('Folders'),
+277 -13
View File
@@ -3,8 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
class SieveScriptEditScreen extends ConsumerStatefulWidget {
const SieveScriptEditScreen({
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
_SieveScriptEditScreenState();
}
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
with SingleTickerProviderStateMixin {
late final TextEditingController _nameController;
late final TextEditingController _contentController;
late final TabController _tabController;
bool _loadingContent = false;
bool _saving = false;
String? _error;
// Visual-editor state.
FilterGroup _filterGroup = FilterGroup.empty();
List<SieveAction> _actions = [];
bool _visualSupported = true;
int _visualLoadCount = 0;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.script?.name ?? '');
_contentController = TextEditingController();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_onTabChanged);
if (widget.script != null) {
unawaited(_loadContent());
}
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
void dispose() {
_nameController.dispose();
_contentController.dispose();
_tabController
..removeListener(_onTabChanged)
..dispose();
super.dispose();
}
void _onTabChanged() {
if (_tabController.indexIsChanging) return;
if (_tabController.index == 1) {
// Switched to Script tab: serialize visual state.
if (_visualSupported) {
_contentController.text =
SieveSerializer().serialize(_filterGroup, _actions);
}
} else {
// Switched to Visual tab: parse script into visual state.
_parseScriptIntoVisual();
}
}
void _parseScriptIntoVisual() {
final result = FilterSieveConverter().parse(_contentController.text);
if (result == null) {
setState(() => _visualSupported = false);
return;
}
setState(() {
_filterGroup = result.group;
_actions = List<SieveAction>.from(result.actions);
_visualSupported = true;
_visualLoadCount++;
});
}
Future<void> _loadContent() async {
setState(() => _loadingContent = true);
try {
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) {
_contentController.text = content;
_parseScriptIntoVisual();
setState(() => _loadingContent = false);
}
} catch (e) {
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
}
Future<void> _save() async {
// Sync visual → script if on visual tab.
if (_tabController.index == 0 && _visualSupported) {
_contentController.text =
SieveSerializer().serialize(_filterGroup, _actions);
}
final name = _nameController.text.trim();
if (name.isEmpty) {
setState(() => _error = 'Name is required');
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
return Scaffold(
appBar: AppBar(
title: Text(isNew ? 'New script' : 'Edit script'),
bottom: TabBar(
controller: _tabController,
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
),
actions: [
if (_saving)
const Padding(
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
const SizedBox(height: 8),
],
Expanded(
child: TextField(
controller: _contentController,
decoration: const InputDecoration(
labelText: 'Script',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace'),
enabled: !_saving,
child: TabBarView(
controller: _tabController,
children: [_buildVisualTab(), _buildScriptTab()],
),
),
],
@@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
),
);
}
Widget _buildVisualTab() {
if (!_visualSupported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'This script uses features not supported by the visual editor.\n'
'Edit as raw Sieve on the Script tab.',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilterBuilderWidget(
key: ValueKey(_visualLoadCount),
initialValue: _filterGroup,
onChanged: (g) => setState(() => _filterGroup = g),
),
const SizedBox(height: 12),
_ActionEditor(
actions: _actions,
onChanged: (a) => setState(() => _actions = a),
),
],
),
);
}
Widget _buildScriptTab() {
return TextField(
controller: _contentController,
decoration: const InputDecoration(
labelText: 'Script',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace'),
enabled: !_saving,
);
}
}
// ---------------------------------------------------------------------------
// Action editor
// ---------------------------------------------------------------------------
enum _ActionType { keep, discard, markAsRead, fileInto }
class _ActionEditor extends StatelessWidget {
const _ActionEditor({required this.actions, required this.onChanged});
final List<SieveAction> actions;
final void Function(List<SieveAction>) onChanged;
_ActionType _typeOf(SieveAction a) => switch (a) {
KeepAction() => _ActionType.keep,
DiscardAction() => _ActionType.discard,
MarkAsSeenAction() => _ActionType.markAsRead,
FileIntoAction() => _ActionType.fileInto,
FlagAction() => _ActionType.keep,
};
SieveAction _defaultFor(_ActionType t) => switch (t) {
_ActionType.keep => KeepAction(),
_ActionType.discard => DiscardAction(),
_ActionType.markAsRead => MarkAsSeenAction(),
_ActionType.fileInto => FileIntoAction(''),
};
void _changeType(int i, _ActionType t) {
final next = List<SieveAction>.from(actions);
final current = next[i];
if (t == _ActionType.fileInto && current is FileIntoAction) return;
next[i] = _defaultFor(t);
onChanged(next);
}
void _changeFolder(int i, String folder) {
final next = List<SieveAction>.from(actions);
next[i] = FileIntoAction(folder);
onChanged(next);
}
void _remove(int i) {
final next = List<SieveAction>.from(actions)..removeAt(i);
onChanged(next);
}
void _add() {
onChanged([...actions, KeepAction()]);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('Actions', style: Theme.of(context).textTheme.labelLarge),
),
for (var i = 0; i < actions.length; i++) _buildRow(context, i),
TextButton.icon(
onPressed: _add,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add action'),
),
],
);
}
Widget _buildRow(BuildContext context, int i) {
final action = actions[i];
final type = _typeOf(action);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
DropdownButton<_ActionType>(
value: type,
isDense: true,
underline: const SizedBox.shrink(),
onChanged: (t) {
if (t != null) _changeType(i, t);
},
items: const [
DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')),
DropdownMenuItem(
value: _ActionType.discard,
child: Text('Discard'),
),
DropdownMenuItem(
value: _ActionType.markAsRead,
child: Text('Mark as read'),
),
DropdownMenuItem(
value: _ActionType.fileInto,
child: Text('File into'),
),
],
),
if (type == _ActionType.fileInto) ...[
const SizedBox(width: 8),
Expanded(
child: _FolderField(
value: (action as FileIntoAction).folder,
onChanged: (v) => _changeFolder(i, v),
),
),
] else
const Spacer(),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
tooltip: 'Remove',
onPressed: () => _remove(i),
),
],
),
);
}
}
class _FolderField extends StatefulWidget {
const _FolderField({required this.value, required this.onChanged});
final String value;
final void Function(String) onChanged;
@override
State<_FolderField> createState() => _FolderFieldState();
}
class _FolderFieldState extends State<_FolderField> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(_FolderField old) {
super.didUpdateWidget(old);
if (widget.value != _ctrl.text) _ctrl.text = widget.value;
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
onChanged: widget.onChanged,
decoration: const InputDecoration(
hintText: 'folder',
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
);
}
}
+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()),
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
/// A widget that lets the user build a structured [FilterGroup] interactively.
///
/// Use a [ValueKey] on this widget when replacing [initialValue] from the
/// outside (e.g., after loading a Sieve script) to force a full rebuild.
class FilterBuilderWidget extends StatefulWidget {
const FilterBuilderWidget({
super.key,
required this.initialValue,
required this.onChanged,
});
final FilterGroup initialValue;
final void Function(FilterGroup) onChanged;
@override
State<FilterBuilderWidget> createState() => _FilterBuilderWidgetState();
}
class _FilterBuilderWidgetState extends State<FilterBuilderWidget> {
late FilterGroup _group;
@override
void initState() {
super.initState();
_group = widget.initialValue;
}
void _update(FilterGroup g) {
setState(() => _group = g);
widget.onChanged(g);
}
@override
Widget build(BuildContext context) {
return _GroupEditor(
group: _group,
onChanged: _update,
depth: 0,
);
}
}
// ---------------------------------------------------------------------------
// Group editor
// ---------------------------------------------------------------------------
class _GroupEditor extends StatelessWidget {
const _GroupEditor({
super.key,
required this.group,
required this.onChanged,
required this.depth,
this.onRemoveGroup,
});
final FilterGroup group;
final void Function(FilterGroup) onChanged;
final int depth;
final VoidCallback? onRemoveGroup;
static const _maxDepth = 1;
void _setOperator(FilterOperator op) =>
onChanged(group.copyWith(operator: op));
void _addLeaf() {
final leaf = FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: '',
);
onChanged(group.copyWith(children: [...group.children, leaf]));
}
void _addSubGroup() {
final sub = FilterGroup(
operator: FilterOperator.and_,
children: [],
);
onChanged(group.copyWith(children: [...group.children, sub]));
}
void _replaceChild(int index, FilterNode node) {
final next = List<FilterNode>.from(group.children);
next[index] = node;
onChanged(group.copyWith(children: next));
}
void _removeChild(int index) {
final next = List<FilterNode>.from(group.children)..removeAt(index);
onChanged(group.copyWith(children: next));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRoot = depth == 0;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_OperatorRow(
operator: group.operator,
onChanged: _setOperator,
onRemove: onRemoveGroup,
),
for (var i = 0; i < group.children.length; i++) _buildChild(context, i),
const SizedBox(height: 6),
Row(
children: [
TextButton.icon(
onPressed: _addLeaf,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add condition'),
),
if (depth < _maxDepth)
TextButton.icon(
onPressed: _addSubGroup,
icon: const Icon(Icons.playlist_add, size: 16),
label: const Text('Add group'),
),
],
),
],
);
if (isRoot) return content;
return Card(
margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
color: theme.colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.all(8),
child: content,
),
);
}
Widget _buildChild(BuildContext context, int i) {
final child = group.children[i];
return switch (child) {
final FilterLeaf leaf => _LeafRow(
key: ValueKey(i),
leaf: leaf,
onChanged: (l) => _replaceChild(i, l),
onDelete: () => _removeChild(i),
),
final FilterGroup sub => _GroupEditor(
key: ValueKey(i),
group: sub,
onChanged: (g) => _replaceChild(i, g),
depth: depth + 1,
onRemoveGroup: () => _removeChild(i),
),
};
}
}
// ---------------------------------------------------------------------------
// Operator row (AND / OR toggle)
// ---------------------------------------------------------------------------
class _OperatorRow extends StatelessWidget {
const _OperatorRow({
required this.operator,
required this.onChanged,
this.onRemove,
});
final FilterOperator operator;
final void Function(FilterOperator) onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return Row(
children: [
SegmentedButton<FilterOperator>(
segments: const [
ButtonSegment(value: FilterOperator.and_, label: Text('AND')),
ButtonSegment(value: FilterOperator.or_, label: Text('OR')),
],
selected: {operator},
onSelectionChanged: (s) => onChanged(s.first),
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const Spacer(),
if (onRemove != null)
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: 'Remove group',
onPressed: onRemove,
),
],
);
}
}
// ---------------------------------------------------------------------------
// Leaf row (field | comparison | value | delete)
// ---------------------------------------------------------------------------
class _LeafRow extends StatefulWidget {
const _LeafRow({
super.key,
required this.leaf,
required this.onChanged,
required this.onDelete,
});
final FilterLeaf leaf;
final void Function(FilterLeaf) onChanged;
final VoidCallback onDelete;
@override
State<_LeafRow> createState() => _LeafRowState();
}
class _LeafRowState extends State<_LeafRow> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.leaf.value);
}
@override
void didUpdateWidget(_LeafRow old) {
super.didUpdateWidget(old);
if (widget.leaf.value != _ctrl.text) {
_ctrl.text = widget.leaf.value;
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onFieldChanged(FilterField? f) {
if (f == null) return;
final allowed = f.allowedComparisons;
final comp = allowed.contains(widget.leaf.comparison)
? widget.leaf.comparison
: allowed.first;
widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp));
}
void _onCompChanged(FilterComparison? c) {
if (c == null) return;
widget.onChanged(widget.leaf.copyWith(comparison: c));
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
DropdownButton<FilterField>(
value: widget.leaf.field,
onChanged: _onFieldChanged,
isDense: true,
underline: const SizedBox.shrink(),
items: FilterField.values
.map(
(f) => DropdownMenuItem(value: f, child: Text(f.label)),
)
.toList(),
),
const SizedBox(width: 8),
DropdownButton<FilterComparison>(
value: widget.leaf.comparison,
onChanged: _onCompChanged,
isDense: true,
underline: const SizedBox.shrink(),
items: widget.leaf.field.allowedComparisons
.map(
(c) => DropdownMenuItem(value: c, child: Text(c.label)),
)
.toList(),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _ctrl,
onChanged: (v) =>
widget.onChanged(widget.leaf.copyWith(value: v)),
decoration: const InputDecoration(
hintText: 'value',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
tooltip: 'Remove',
onPressed: widget.onDelete,
),
],
),
);
}
}
+51 -43
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:
@@ -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:
@@ -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
+1
View File
@@ -87,6 +87,7 @@ const _excluded = {
'lib/ui/widgets/email_thread_tile.dart',
'lib/ui/screens/trusted_image_senders_screen.dart',
'lib/data/repositories/note_repository_impl.dart',
'lib/ui/widgets/filter_builder.dart',
'lib/ui/widgets/thread_tile.dart',
};
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.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/core/models/mailbox.dart';
@@ -272,6 +273,13 @@ class _FakeEmails implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
+4 -1
View File
@@ -10,6 +10,9 @@
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
@Tags(['nightly'])
library;
import 'dart:io';
import 'dart:math';
@@ -132,7 +135,7 @@ void main() {
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
() async {
timeout: Timeout.none, () async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
+7
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:mockito/annotations.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/core/models/mailbox.dart';
@@ -137,6 +138,12 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
@@ -7,6 +7,7 @@ import 'dart:async' as _i5;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i10;
import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
@@ -545,6 +546,22 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i5.Future<List<_i3.Email>> searchEmailsStructured(
String? accountId,
_i10.FilterGroup? filter,
) =>
(super.noSuchMethod(
Invocation.method(
#searchEmailsStructured,
[
accountId,
filter,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i5.Future<List<_i3.Email>> getEmailsByAddress(
String? accountId,
+196 -4
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');
@@ -514,8 +559,7 @@ void main() {
),
);
final results =
await r.emails.searchEmailsGlobal(null, 'urgent');
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
expect(results, hasLength(1));
expect(results.first.subject, 'Weekly report');
});
@@ -569,13 +613,161 @@ void main() {
),
);
final results =
await r.emails.searchEmails('acc-1', 'INBOX', 'client');
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
expect(results, hasLength(1));
expect(results.first.subject, 'Project update');
expect(results.first.mailboxPath, 'INBOX');
});
test('searchEmailsGlobal 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 report'),
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 report'),
receivedAt: DateTime(2024, 6),
),
);
final results = await r.emails.searchEmailsGlobal(null, 'report');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer report');
expect(results[1].subject, 'Older report');
});
test('searchEmails 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 meeting'),
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 meeting'),
receivedAt: DateTime(2024, 6),
),
);
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer meeting');
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 {
+337
View File
@@ -0,0 +1,337 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
void main() {
group('FilterGroup', () {
test('empty() creates an empty group', () {
final g = FilterGroup.empty();
expect(g.isEmpty, isTrue);
expect(g.children, isEmpty);
expect(g.operator, FilterOperator.and_);
});
test('non-empty group is not isEmpty', () {
final g = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'test',
),
],
);
expect(g.isEmpty, isFalse);
});
test('copyWith changes operator', () {
final g = FilterGroup.empty().copyWith(operator: FilterOperator.or_);
expect(g.operator, FilterOperator.or_);
});
test('copyWith changes children', () {
final leaf = FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'hello',
);
final g = FilterGroup.empty().copyWith(children: [leaf]);
expect(g.children, hasLength(1));
});
});
group('FilterLeaf', () {
test('copyWith changes field', () {
final leaf = FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'x',
);
final updated = leaf.copyWith(field: FilterField.to);
expect(updated.field, FilterField.to);
expect(updated.comparison, FilterComparison.contains);
expect(updated.value, 'x');
});
test('copyWith changes value', () {
final leaf = FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.is_,
value: 'old',
);
final updated = leaf.copyWith(value: 'new');
expect(updated.value, 'new');
expect(updated.field, FilterField.subject);
});
test('size field allows over/under comparisons', () {
expect(
FilterField.size.allowedComparisons,
containsAll([FilterComparison.over, FilterComparison.under]),
);
});
test('address fields do not allow over/under', () {
for (final f in [FilterField.from_, FilterField.to, FilterField.cc]) {
expect(f.allowedComparisons, isNot(contains(FilterComparison.over)));
expect(f.allowedComparisons, isNot(contains(FilterComparison.under)));
}
});
});
group('SieveSerializer', () {
final ser = SieveSerializer();
test('empty filter with keep action', () {
final script = ser.serialize(FilterGroup.empty(), [KeepAction()]);
expect(script, contains('keep;'));
expect(script, isNot(contains('if ')));
});
test('single from-contains condition', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'alice',
),
],
);
final script = ser.serialize(group, [FileIntoAction('Work')]);
expect(script, contains('require'));
expect(script, contains('fileinto'));
expect(script, contains('"Work"'));
expect(script, contains(':contains'));
expect(script, contains('"from"'));
expect(script, contains('"alice"'));
});
test('AND group serialises as allof', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'supplier',
),
],
);
final script = ser.serialize(group, [KeepAction()]);
expect(script, contains('allof'));
});
test('OR group serialises as anyof', () {
final group = FilterGroup(
operator: FilterOperator.or_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'a',
),
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'b',
),
],
);
final script = ser.serialize(group, [DiscardAction()]);
expect(script, contains('anyof'));
expect(script, contains('discard;'));
});
test('size over condition', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.size,
comparison: FilterComparison.over,
value: '1000000',
),
],
);
final script = ser.serialize(group, [DiscardAction()]);
expect(script, contains('size :over 1000000'));
});
test('mark-as-seen action emits setflag', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'newsletter',
),
],
);
final script = ser.serialize(group, [MarkAsSeenAction()]);
expect(script, contains('setflag'));
expect(script, contains(r'\Seen'));
});
test('escapes quotes in values', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'say "hello"',
),
],
);
final script = ser.serialize(group, [KeepAction()]);
expect(script, contains(r'say \"hello\"'));
});
});
group('FilterSieveConverter', () {
final conv = FilterSieveConverter();
test('returns null for empty script', () {
expect(conv.parse(''), isNull);
});
test('parses simple address test', () {
const script = '''
if address :contains "from" "alice@example.com" {
keep;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.children, hasLength(1));
final leaf = result.group.children.first as FilterLeaf;
expect(leaf.field, FilterField.from_);
expect(leaf.comparison, FilterComparison.contains);
expect(leaf.value, 'alice@example.com');
expect(result.actions, hasLength(1));
expect(result.actions.first, isA<KeepAction>());
});
test('parses subject header test', () {
const script = '''
if header :is "subject" "Hello" {
fileinto "Inbox";
}''';
final result = conv.parse(script);
expect(result, isNotNull);
final leaf = result!.group.children.first as FilterLeaf;
expect(leaf.field, FilterField.subject);
expect(leaf.comparison, FilterComparison.is_);
expect(leaf.value, 'Hello');
final action = result.actions.first as FileIntoAction;
expect(action.folder, 'Inbox');
});
test('parses allof group as AND', () {
const script = '''
if allof(
address :contains "from" "alice",
header :contains "subject" "invoice"
) {
keep;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.operator, FilterOperator.and_);
expect(result.group.children, hasLength(2));
});
test('parses anyof group as OR', () {
const script = '''
if anyof(
address :contains "from" "a",
address :contains "from" "b"
) {
discard;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.operator, FilterOperator.or_);
expect(result.actions.first, isA<DiscardAction>());
});
test('parses size over test', () {
const script = '''
if size :over 500000 {
discard;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
final leaf = result!.group.children.first as FilterLeaf;
expect(leaf.field, FilterField.size);
expect(leaf.comparison, FilterComparison.over);
expect(leaf.value, '500000');
});
test('parses setflag \\\\Seen as MarkAsSeenAction', () {
const script = r'''
if header :contains "subject" "newsletter" {
setflag "\\Seen";
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.actions.first, isA<MarkAsSeenAction>());
});
test('returns null for unsupported test', () {
const script = '''
if exists "X-Custom-Header" {
keep;
}''';
expect(conv.parse(script), isNull);
});
test('round-trips through serializer', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'alice@example.com',
),
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
],
);
final actions = <SieveAction>[FileIntoAction('Work')];
final script = SieveSerializer().serialize(group, actions);
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.operator, FilterOperator.and_);
expect(result.group.children, hasLength(2));
expect(result.actions, hasLength(1));
expect((result.actions.first as FileIntoAction).folder, 'Work');
});
test('parses require block and ignores it', () {
const script = '''
require ["fileinto"];
if address :contains "from" "bob" {
fileinto "Archive";
}''';
final result = conv.parse(script);
expect(result, isNotNull);
final leaf = result!.group.children.first as FilterLeaf;
expect(leaf.value, 'bob');
});
});
}
+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();
@@ -4,6 +4,7 @@
// checked the _running flag (only true after start() is called).
import 'package:flutter_test/flutter_test.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/core/models/mailbox.dart';
@@ -144,6 +145,12 @@ class _FakeEmails implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
+7
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.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/core/models/mailbox.dart';
@@ -140,6 +141,12 @@ class _CountingEmails implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
+24 -7
View File
@@ -7,10 +7,11 @@ import 'dart:async' as _i4;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
import 'package:sharedinbox/core/models/undo_action.dart' as _i8;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -342,6 +343,22 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<List<_i2.Email>> searchEmailsStructured(
String? accountId,
_i6.FilterGroup? filter,
) =>
(super.noSuchMethod(
Invocation.method(
#searchEmailsStructured,
[
accountId,
filter,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<List<_i2.Email>> getEmailsByAddress(
String? accountId,
@@ -558,13 +575,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
/// A class which mocks [UndoRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
MockUndoRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
Invocation.method(
#saveAction,
[action],
@@ -584,15 +601,15 @@ class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
) as _i4.Future<void>);
@override
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
(super.noSuchMethod(
Invocation.method(
#getHistory,
[],
{#limit: limit},
),
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
) as _i4.Future<List<_i7.UndoAction>>);
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
) as _i4.Future<List<_i8.UndoAction>>);
@override
_i4.Future<void> clearHistory() => (super.noSuchMethod(
+4 -1
View File
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
FakeAccountRepository(accounts),
),
],
child: const MaterialApp(home: AboutScreen()),
child: MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: const AboutScreen(),
),
);
}
+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',
+18
View File
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/models/draft.dart';
@@ -43,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';
// ---------------------------------------------------------------------------
@@ -365,6 +367,13 @@ class FakeEmailRepository implements EmailRepository {
) async =>
_searchResults;
@override
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(
String? accountId,
@@ -476,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(
@@ -688,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);
});
});
}