Compare commits

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

Closes #506

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:49:05 +00:00
46 changed files with 342 additions and 2973 deletions
+13 -6
View File
@@ -19,14 +19,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+94 -43
View File
@@ -21,14 +21,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -51,27 +58,43 @@ jobs:
HEAD_SHA=$(git rev-parse 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.
# 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.
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"
base_api = f"{server}/api/v1/repos/{repo}/actions"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
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)
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
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
@@ -148,14 +171,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -199,14 +229,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -244,14 +281,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -294,14 +338,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+26 -12
View File
@@ -20,14 +20,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -73,14 +80,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+13 -99
View File
@@ -12,103 +12,10 @@ 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
@@ -117,14 +24,21 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created=$(curl -sf --max-time 30 \
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ 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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+1 -1
View File
@@ -32,7 +32,7 @@ repos:
- id: dart-check
name: dart format (autofix) + check-fast (parallel)
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/precommit_dart_check.sh'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
pass_filenames: false
always_run: true
- id: ci-no-direct-dagger
+9 -13
View File
@@ -13,27 +13,23 @@ 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 routes to `loop/merge` |
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
**State machine:**
```
loop/plan → loop/plan-in-process → loop/plan-done
↘ NeedSupervisor (on failure)
loop/plan → loop/plan-in-progress → loop/plan-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)
loop/code → loop/code-in-progress → loop/code-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 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.
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
- Planning agents only post a comment — they do NOT write code or open PRs.
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
@@ -43,9 +39,9 @@ loop/merge → loop/merge-in-process → loop/merge-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 + hands off to merge
5. (Optional) Review PR before it merges
6. Merge agent waits for CI and merges the PR automatically
4. Add label loop/code → agent implements + opens PR
5. Review PR, merge
6. Close issue
```
## Code conventions
+36 -40
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\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
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"}).
WithDirectory("/tmp/stalwart", dataDir).
WithExposedPort(8080). // JMAP
WithExposedPort(1430). // IMAP
@@ -503,19 +503,23 @@ func (m *Ci) CheckFast(ctx context.Context) (string, error) {
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It reuses the codegenBase() output instead of running build_runner a second time,
// diffing committed generated files against the freshly built ones.
// It snapshots the committed source (including any stale generated files) before
// running build_runner, so git diff detects real staleness instead of always
// comparing two freshly-generated outputs.
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
fresh := m.codegenBase().Directory("/src")
return m.pubGetLayer().
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
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"}).
WithExec([]string{"/bin/bash", "-c",
`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`}).
`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.\""}).
Stdout(ctx)
}
@@ -535,7 +539,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 --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
@@ -566,7 +570,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 --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
@@ -590,33 +594,25 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return "", 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
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
}
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
+3
View File
@@ -124,6 +124,9 @@
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
export IN_NIX_SHELL=1
# Point Dagger client at the running engine socket
export DAGGER_HOST=unix:///run/dagger/engine.sock
# Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 41;
const int dbSchemaVersion = 40;
-88
View File
@@ -1,88 +0,0 @@
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
@@ -1,358 +0,0 @@
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,4 +1,3 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository {
@@ -62,12 +61,6 @@ 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);
+8 -2
View File
@@ -1,7 +1,6 @@
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.
@@ -103,11 +102,18 @@ 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
@@ -1,100 +0,0 @@
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
@@ -1,9 +0,0 @@
/// 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,116 +679,6 @@ 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()),
),
),
);
}
}
}
},
);
+25 -105
View File
@@ -9,7 +9,6 @@ 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';
@@ -561,7 +560,7 @@ class EmailRepositoryImpl implements EmailRepository {
for (final msg in result.messages) {
final uid = msg.uid;
if (uid == null) continue;
final emailId = '${account.id}:$mailboxPath:$uid';
final emailId = '${account.id}:$uid';
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
EmailsCompanion(
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
@@ -616,7 +615,7 @@ class EmailRepositoryImpl implements EmailRepository {
continue;
}
bytes += msg.size ?? 0;
final emailId = '${account.id}:$mailboxPath:$uid';
final emailId = '${account.id}:$uid';
final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim();
@@ -2923,9 +2922,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 e.received_at DESC LIMIT 50'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
@@ -2943,7 +2942,6 @@ 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;
}
@@ -2954,12 +2952,16 @@ 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>>[];
@@ -2978,99 +2980,15 @@ 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)));
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))
final rows = await _db
.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
)
.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();
final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
return emailRows.map(_toModel).toList();
}
/// Converts a user query string into an FTS5 match expression.
@@ -3079,7 +2997,9 @@ class EmailRepositoryImpl implements EmailRepository {
static String _toFtsQuery(String query) {
final words = query
.trim()
.split(RegExp(r'[^\w]+'))
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.where((w) => w.isNotEmpty)
.toList();
if (words.isEmpty) return '';
@@ -3193,7 +3113,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 e.received_at DESC LIMIT 50';
' ORDER BY rank LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
@@ -3206,14 +3126,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,7 +109,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -117,7 +116,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
routerConfig: router,
);
-1
View File
@@ -57,7 +57,6 @@ 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'),
+9 -12
View File
@@ -16,7 +16,6 @@ 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';
@@ -74,6 +73,10 @@ 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),
@@ -129,20 +132,12 @@ 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'),
@@ -170,6 +165,8 @@ 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);
@@ -211,8 +208,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final senderEmail = header?.from.isNotEmpty == true
? header!.from.first.email.toLowerCase()
: null;
final isTrusted = senderEmail != null &&
trustedSenders.any((p) => globMatch(senderEmail, p));
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return ListView(
+1 -8
View File
@@ -278,14 +278,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
),
],
onChanged: _onSearchChanged,
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));
}
},
onSubmitted: _runSearch,
textInputAction: TextInputAction.search,
),
),
+11 -106
View File
@@ -4,12 +4,10 @@ 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>>((
@@ -39,10 +37,6 @@ 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();
@@ -59,13 +53,6 @@ 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) {
@@ -148,47 +135,22 @@ 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: _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,
),
title: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
actions: [
if (!_advancedMode && _ctrl.text.isNotEmpty)
if (_ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
@@ -196,15 +158,6 @@ 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(),
@@ -212,7 +165,6 @@ 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) {
@@ -222,54 +174,7 @@ 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'),
+13 -277
View File
@@ -3,13 +3,8 @@ 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({
@@ -32,29 +27,18 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
_SieveScriptEditScreenState();
}
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
with SingleTickerProviderStateMixin {
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
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());
}
@@ -64,40 +48,9 @@ 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 {
@@ -110,7 +63,6 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) {
_contentController.text = content;
_parseScriptIntoVisual();
setState(() => _loadingContent = false);
}
} catch (e) {
@@ -124,11 +76,6 @@ 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');
@@ -171,10 +118,6 @@ 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(
@@ -220,9 +163,18 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
const SizedBox(height: 8),
],
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildVisualTab(), _buildScriptTab()],
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,
),
),
],
@@ -230,220 +182,4 @@ 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),
),
);
}
}
+2 -3
View File
@@ -8,7 +8,6 @@ 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';
@@ -119,8 +118,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
final senderEmail = widget.email.from.isNotEmpty
? widget.email.from.first.email.toLowerCase()
: null;
final isTrusted = senderEmail != null &&
trustedSenders.any((p) => globMatch(senderEmail, p));
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
@@ -16,11 +16,6 @@ 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: (_, __) =>
@@ -31,8 +26,7 @@ class TrustedImageSendersScreen extends ConsumerWidget {
padding: EdgeInsets.all(16),
child: Text(
'No addresses added yet. '
'Tap + to add an address or pattern (e.g. *@example.com), '
'or tap "Load remote images" in an email to add the sender automatically.',
'Tap "Load remote images" in an email to add the sender.',
),
);
}
@@ -66,61 +60,4 @@ 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
@@ -1,312 +0,0 @@
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,
),
],
),
);
}
}
+43 -51
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev"
source: hosted
version: "99.0.0"
version: "93.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev"
source: hosted
version: "12.1.0"
version: "10.0.1"
archive:
dependency: transitive
description:
@@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.0.0"
code_builder:
dependency: transitive
description:
@@ -237,18 +237,18 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.8"
version: "3.1.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.14"
version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
@@ -349,10 +349,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
url: "https://pub.dev"
source: hosted
version: "12.0.0-beta.5"
version: "12.0.0-beta.4"
fixnum:
dependency: transitive
description:
@@ -391,42 +391,34 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev"
source: hosted
version: "22.0.0"
version: "21.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev"
source: hosted
version: "8.0.1"
version: "8.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev"
source: hosted
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"
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
@@ -439,10 +431,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
version: "2.0.34"
flutter_riverpod:
dependency: "direct main"
description:
@@ -455,10 +447,10 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
url: "https://pub.dev"
source: hosted
version: "10.3.1"
version: "10.3.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
@@ -534,10 +526,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
url: "https://pub.dev"
source: hosted
version: "17.3.0"
version: "17.2.3"
graphs:
dependency: transitive
description:
@@ -550,10 +542,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "1.0.3"
http:
dependency: "direct main"
description:
@@ -715,10 +707,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.19.1"
version: "0.17.6"
node_preamble:
dependency: transitive
description:
@@ -731,10 +723,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
version: "9.3.0"
open_filex:
dependency: "direct main"
description:
@@ -1021,13 +1013,13 @@ packages:
source: hosted
version: "1.10.2"
sqlite3:
dependency: "direct main"
dependency: "direct dev"
description:
name: sqlite3
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
version: "3.3.1"
sqlite3_flutter_libs:
dependency: "direct main"
description:
@@ -1096,10 +1088,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
@@ -1296,10 +1288,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_android
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
url: "https://pub.dev"
source: hosted
version: "4.13.0"
version: "4.12.0"
webview_flutter_platform_interface:
dependency: transitive
description:
@@ -1312,10 +1304,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
url: "https://pub.dev"
source: hosted
version: "3.26.0"
version: "3.25.1"
win32:
dependency: transitive
description:
@@ -1389,5 +1381,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
+3 -3
View File
@@ -28,7 +28,7 @@ dependencies:
flutter_riverpod: ^3.0.0
# Navigation
go_router: ^17.3.0
go_router: ^17.2.3
# 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.5
file_picker: ^12.0.0-beta.4
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: ^22.0.0
flutter_local_notifications: ^21.0.0
workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
-1
View File
@@ -87,7 +87,6 @@ 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',
};
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# Pre-commit wrapper for the `dart-check` hook.
#
# `dagger call ... check-fast` needs a Dagger engine. On a dev machine or in
# CI that engine is provisioned from a local container runtime (docker/podman)
# or reached through _EXPERIMENTAL_DAGGER_RUNNER_HOST. In engine-less sandboxes
# (e.g. the agentloop agent pods that commit on our behalf) none of those
# exist, so dagger falls back to its default engine image reference and aborts
# with:
# start engine: driver for scheme "image" was not available
# which blocked every commit the agent tried to make.
#
# Codeberg CI still runs check-fast on every push, so skipping here is safe:
# warn loudly and let the commit through when no engine can be reached.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# True when dagger has some way to reach/provision an engine.
engine_available() {
# A shared engine reached over the wire wins outright.
[ -n "${_EXPERIMENTAL_DAGGER_RUNNER_HOST:-}" ] && return 0
# Otherwise dagger provisions the engine from a local container runtime.
# `info` (not `version`) confirms the daemon is actually reachable; cap it
# with a timeout so a stale docker context cannot hang the commit.
if command -v docker >/dev/null 2>&1 && timeout 10 docker info >/dev/null 2>&1; then
return 0
fi
if command -v podman >/dev/null 2>&1 && timeout 10 podman info >/dev/null 2>&1; then
return 0
fi
return 1
}
if ! engine_available; then
echo "WARNING: no Dagger engine available (no container runtime, and" \
"_EXPERIMENTAL_DAGGER_RUNNER_HOST is unset); skipping dart-check." \
"Codeberg CI still runs check-fast on push." >&2
exit 0
fi
exec nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast
@@ -3,7 +3,6 @@ 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';
@@ -273,13 +272,6 @@ 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 => [];
+1 -4
View File
@@ -10,9 +10,6 @@
// 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';
@@ -135,7 +132,7 @@ void main() {
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
timeout: Timeout.none, () async {
() async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
-7
View File
@@ -2,7 +2,6 @@ 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';
@@ -138,12 +137,6 @@ 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,7 +7,6 @@ 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;
@@ -546,22 +545,6 @@ 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,
+4 -196
View File
@@ -7,7 +7,6 @@ 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;
@@ -263,50 +262,6 @@ 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');
@@ -559,7 +514,8 @@ 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');
});
@@ -613,161 +569,13 @@ 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
@@ -1,337 +0,0 @@
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
@@ -1,50 +0,0 @@
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);
});
});
}
+2 -179
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 41);
expect(db.schemaVersion, 40);
await db.close();
});
@@ -435,184 +435,7 @@ void main() {
},
);
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 {
test('fresh install creates all tables at schemaVersion 40', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -4,7 +4,6 @@
// 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';
@@ -145,12 +144,6 @@ 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,7 +1,6 @@
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';
@@ -141,12 +140,6 @@ 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(
+7 -24
View File
@@ -7,11 +7,10 @@ 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 _i8;
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -343,22 +342,6 @@ 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,
@@ -575,13 +558,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 _i7.UndoRepository {
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
MockUndoRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
Invocation.method(
#saveAction,
[action],
@@ -601,15 +584,15 @@ class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
) as _i4.Future<void>);
@override
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
(super.noSuchMethod(
Invocation.method(
#getHistory,
[],
{#limit: limit},
),
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
) as _i4.Future<List<_i8.UndoAction>>);
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
) as _i4.Future<List<_i7.UndoAction>>);
@override
_i4.Future<void> clearHistory() => (super.noSuchMethod(
+1 -4
View File
@@ -50,10 +50,7 @@ Widget _buildScreen({List<Account> accounts = const []}) {
FakeAccountRepository(accounts),
),
],
child: MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: const AboutScreen(),
),
child: const MaterialApp(home: AboutScreen()),
);
}
+13 -13
View File
@@ -81,7 +81,7 @@ void main() {
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows subject in email header section', (tester) async {
testWidgets('shows subject in app bar after data loads', (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 only in the email header section, not in the app bar.
expect(find.text('Project update'), findsOneWidget);
// Subject appears in both the app bar and the email header section.
expect(find.text('Project update'), findsAtLeastNWidgets(1));
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 a standalone button, not in popup menu', (
testWidgets('Mark as spam is in popup menu, not a standalone button', (
tester,
) async {
await tester.pumpWidget(
@@ -279,19 +279,19 @@ void main() {
);
await tester.pumpAndSettle();
// Standalone icon button for mark as spam is in the app bar.
// No standalone icon button for mark as spam.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsOneWidget,
findsNothing,
);
// It does NOT appear in the popup menu.
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsNothing);
expect(find.text('Mark as spam'), findsOneWidget);
});
testWidgets('Mark as spam shows dialog when no junk folder', (
@@ -309,11 +309,11 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
);
// 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.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
+2 -63
View File
@@ -446,10 +446,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail body header shows the first email's subject.
// The detail AppBar title shows the first email's subject.
expect(
find.descendant(
of: find.byType(EmailDetailScreen),
of: find.byType(AppBar),
matching: find.text('Alpha Match'),
),
findsOneWidget,
@@ -798,67 +798,6 @@ 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,7 +10,6 @@ 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';
@@ -44,7 +43,6 @@ 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';
// ---------------------------------------------------------------------------
@@ -367,13 +365,6 @@ class FakeEmailRepository implements EmailRepository {
) async =>
_searchResults;
@override
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(
String? accountId,
@@ -485,12 +476,6 @@ 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(
@@ -703,9 +688,6 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
AfterMailViewAction afterMailViewAction;
final List<String> _trustedImageSenders;
List<String> get trustedImageSendersForTest =>
List.unmodifiable(_trustedImageSenders);
@override
Stream<UserPreferences> observePreferences() => Stream.value(
UserPreferences(
@@ -1,163 +0,0 @@
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);
});
});
}