Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 a96ae3c0d7 feat: add SSH deploy secrets (SSH_PRIVATE_KEY, SSH_KNOWN_HOSTS, SSH_USER, SSH_HOST) to SOPS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 06:29:00 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 1cd1e49430 feat: migrate CI secrets from Forgejo to SOPS, remove all fallbacks
- Add 6 secrets to secrets.enc.yaml: WEBSITE_SSH_HOST, PLAY_STORE_CONFIG_JSON,
  ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD,
  FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY, RENOVATE_FORGEJO_TOKEN
- Extend setup_dagger_remote.sh to export all CI secrets from SOPS to
  GITHUB_ENV so subsequent steps receive them without Forgejo secret refs
- Remove all silent-skip fallbacks (if: secrets.X != '') from deploy.yml,
  website.yml, firebase-tests.yml — jobs now fail hard if secrets are missing
- Remove direct Forgejo secret references from all workflow env: blocks
- Delete temporary dump-secrets workflow

SSH_PRIVATE_KEY, SSH_KNOWN_HOSTS, SSH_USER, SSH_HOST are not yet in Forgejo
and therefore not in SOPS — deploy/website tasks will fail with a clear
Taskfile precondition error until those secrets are provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:14:53 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ef4448e8b6 chore: post age-encrypted secrets as PR comment for extraction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:59:56 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 16582fef8f chore: restore full age-encryption logic for secret dump
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:52:21 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 86798065d3 chore: test all 10 secrets in env with simple check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:47:04 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7d9a8fa30b chore: test dump-secrets with one secret in env
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:41:50 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 ac96329337 chore: test minimal dump-secrets job to debug failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:35:34 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cfca2a74f7 chore: switch dump-secrets trigger to push on sops-migrate branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:27:57 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 28bcbdacf4 chore: add temporary dump-secrets workflow to extract values for SOPS migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:21:00 +02:00
dbc9d4dac8 fix: migrate jvmTarget to compilerOptions DSL for Kotlin 2.x (#352)
## Summary

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

## Test plan

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

Closes #351

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:20:48 +02:00
Bot of Thomas Güttler d905cd653f fix: check Docker availability before falling back to local Dagger engine (#329) (#333) 2026-05-29 23:19:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e21cde0a3c fix: allow forgejo-actions as issue author in agent loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:52:56 +02:00
Bot of Thomas Güttler 50a6678ec2 feat: reimplement user preferences, archive, configurable navigation (#315) (#324) 2026-05-29 19:08:12 +02:00
Bot of Thomas Güttler 91083218d4 fix: diff from last deployed SHA to catch all changes since last deploy (#320) (#332) 2026-05-29 17:34:21 +02:00
Bot of Thomas Güttler adc4eb6f6d feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325) (#330) 2026-05-29 12:53:18 +02:00
Bot of Thomas Güttler 05d00bdf09 fix: move overflow actions into popup menu so three-dot menu is always visible (#312) (#323) 2026-05-28 07:19:11 +02:00
Bot of Thomas Güttler c45775be92 fix: move sync health report to own row below each account (#311) (#322) 2026-05-28 06:53:11 +02:00
47fc534a8d fix: disable github-actions manager to suppress GitHub token warning (#285) (#306)
## Summary

- Disables the `github-actions` Renovate manager in `renovate.json`
- Removes the previous `fileMatch` override that pointed Renovate at Forgejo workflow files
- Stops Renovate from scanning workflow YAML files for action version updates, eliminating GitHub API calls and the "GitHub token is required" warning

## Test plan

- [ ] Verify `renovate.json` is valid JSON (done locally with `python3 -m json.tool`)
- [ ] Confirm the next Renovate run no longer produces the GitHub token warning in its logs

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

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/306
2026-05-28 05:03:02 +02:00
Bot of Thomas Güttler a5928c1aa6 fix: add _tea_get and merged-PR catch-up to close issues on merge (#305) (#310) 2026-05-28 00:07:13 +02:00
Bot of Thomas Güttler 7f3cd43d6e feat: add --dangerously-skip-permissions to claude --resume output (#304) (#309) 2026-05-27 23:48:12 +02:00
Bot of Thomas Güttler f0f210e5ab feat: configurable next action after single mail view (#300) (#308) 2026-05-27 23:33:14 +02:00
90 changed files with 1913 additions and 3745 deletions
-6
View File
@@ -6,12 +6,6 @@
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml # ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
FROM ghcr.io/catthehacker/ubuntu:go-24.04 FROM ghcr.io/catthehacker/ubuntu:go-24.04
# Infrastructure tools required by CI workflows
RUN apt-get update && apt-get install -y --no-install-recommends \
stunnel4 \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
# Dagger CLI — pinned to match the engine version on the runner host # Dagger CLI — pinned to match the engine version on the runner host
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \ RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh | DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
+3 -148
View File
@@ -1,159 +1,14 @@
name: CI name: CI
on: [push, pull_request]
on:
push:
branches: [main]
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
pull_request:
paths:
- 'lib/**'
- 'test/**'
- 'integration_test/**'
- 'android/**'
- 'linux/**'
- 'assets/**'
- '!assets/changelog.txt'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'scripts/**'
- 'stalwart-dev/**'
- 'ci/**'
- 'Taskfile.yml'
- 'drift_schemas/**'
- '.forgejo/workflows/ci.yml'
jobs: jobs:
check: check:
name: Full Project Check name: Full Project Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: - name: Setup Dagger Remote Engine
fetch-depth: 50
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Locate Docker daemon for local Dagger engine
run: |
# Skip if remote Dagger engine is already configured (preferred path)
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
echo "Remote Dagger engine configured, no local Docker needed."
exit 0
fi
# Try host Docker socket (DooD) if runner mounts it
if [ -S /var/run/docker.sock ]; then
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
echo "Docker available via host socket."
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
echo "CI will likely fail at the Dagger step." >&2
- name: Prune Dagger cache before check
env:
DAGGER_NO_NAG: "1"
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
# when total cache exceeds the limit; without args only unreferenced entries are removed.
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Run Full Check Suite - name: Run Full Check Suite
env:
DAGGER_NO_NAG: "1"
run: task check-dagger run: task check-dagger
- name: Prune Dagger cache after check
if: always()
env:
DAGGER_NO_NAG: "1"
run: |
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+19 -91
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 0
- name: Detect Android and Linux changes - name: Detect Android and Linux changes
id: diff id: diff
@@ -48,7 +48,7 @@ jobs:
data = json.loads(r.read()) data = json.loads(r.read())
runs = [ runs = [
r for r in data.get("workflow_runs", []) r for r in data.get("workflow_runs", [])
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success" if r.get("status") == "success"
] ]
print(runs[0].get("commit_sha") or "") print(runs[0].get("commit_sha") or "")
except Exception as e: except Exception as e:
@@ -64,10 +64,17 @@ jobs:
exit 0 exit 0
fi fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files # Diff from the last successfully deployed commit to catch all changes since
# when the parent is unavailable (initial commit, shallow clone). # that deploy, not just the most recent commit. Falls back to HEAD~1 when
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ # LAST_DEPLOYED_SHA is unknown or not in local history.
|| git show --name-only --format= HEAD) if [ -n "$LAST_DEPLOYED_SHA" ] && 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
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
fi
echo "Changed files:" echo "Changed files:"
echo "$CHANGED" echo "$CHANGED"
@@ -99,28 +106,17 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store - name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-android run: task publish-android
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-apk: deploy-apk:
name: Build & Deploy APK to Server name: Build & Deploy APK to Server
@@ -138,31 +134,17 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server - name: Build & Deploy APK to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-apk run: task deploy-apk
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
build-linux: build-linux:
name: Build Linux Release name: Build Linux Release
@@ -180,71 +162,17 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server - name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task deploy-linux run: task deploy-linux
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
+2 -12
View File
@@ -58,28 +58,18 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab - name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env: env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task test-android-firebase run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
- name: Create issue on test failure - name: Create issue on test failure
if: failure() if: failure()
env: env:
-18
View File
@@ -1,18 +0,0 @@
name: Monitor Agent Loop
on:
schedule:
- cron: '0 */2 * * *' # every 2 hours
workflow_dispatch:
jobs:
monitor:
name: Check Agent Loop Health
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check agent loop heartbeat
run: python3 scripts/agent_loop.py monitor
+2 -11
View File
@@ -18,22 +18,13 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Run Renovate - name: Run Renovate
env: env:
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
run: task renovate run: task renovate
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+5 -17
View File
@@ -1,6 +1,8 @@
name: Update Website name: Update Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -24,32 +26,18 @@ jobs:
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh run: scripts/setup_dagger_remote.sh
- name: Build & Update Website - name: Build & Update Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1" DAGGER_NO_NAG: "1"
run: task publish-website run: task publish-website
- name: Verify Website - name: Verify Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }} SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh run: scripts/website-verify.sh
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+27 -32
View File
@@ -8,46 +8,41 @@ CLI tool `fgj` is available to query issues/PRs/actions.
## Issue Label Workflow ## Issue Label Workflow
We use issues, follow this label state machine: Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
- **State/ToPlan** — Issue needs a plan written by an agent before implementation | Label | Trigger | Outcome |
- **State/Planned** — Plan has been posted as a comment; awaiting human review |---|---|---|
- **State/Ready** — Issue is approved and ready for implementation | `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
- **State/InProgress** — Set while an agent (or human) is actively working | `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
- **State/Question** — Agent hit a blocker or needs clarification
Full lifecycle: **State machine:**
``` ```
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent) loop/plan loop/plan-in-progress → loop/plan-done
State/Planned → State/Ready (manual: human reviews the plan and approves) ↘ NeedSupervisor (on failure)
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
State/InProgress → closed (automated: after PR is merged and CI passes) loop/code → loop/code-in-progress loop/code-done
any state → State/Question (automated or manual: when blocked) NeedSupervisor (on failure)
``` ```
List open issues ready to pick up: **Rules:**
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
- Planning agents only post a comment — they do NOT write code or open PRs.
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
**Typical lifecycle for a new feature:**
```bash
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
``` ```
1. Create issue
Rules: 2. Add label loop/plan → agent writes plan as comment
3. Review plan, request changes or approve
- Never start implementation on an issue without `State/Ready` 4. Add label loop/code → agent implements + opens PR
- Planning agents only post a plan comment — they do NOT write code or open PRs 5. Review PR, merge
- After `State/Planned`, a human must review the plan and manually add `State/Ready` 6. Close issue
- When working via the agent loop: label transitions are set automatically ```
by `agent_loop.py` — do **not** set them yourself.
- When working manually: switch to `State/InProgress` as your **first action**:
```bash
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
```
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
- When done and CI is green, close the issue:
```bash
fgj issue close <NUMBER>
```
## Code conventions ## Code conventions
+2
View File
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow ## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands. Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
+5
View File
@@ -216,3 +216,8 @@ test/
- **Settings** — list and remove accounts - **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change - **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send - **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
+12 -3
View File
@@ -294,11 +294,11 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2 echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2 echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
sleep 90 sleep 90
else else
@@ -319,7 +319,16 @@ tasks:
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE" rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
} }
trap cleanup EXIT trap cleanup EXIT
until [ -s "$PORTFILE" ]; do sleep 0.05; done until [ -s "$PORTFILE" ]; do
sleep 0.05
if ! kill -0 "$RECV_PID" 2>/dev/null; then
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
retry_dagger dagger call --progress=plain -q -m ci --source=. check
RC=$?
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
exit $RC
fi
done
PORT=$(cat "$PORTFILE") PORT=$(cat "$PORTFILE")
retry_dagger env \ retry_dagger env \
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \ OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
+4 -2
View File
@@ -16,8 +16,10 @@ android {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
signingConfigs { signingConfigs {
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.3.21" apply false
} }
include(":app") include(":app")
+1 -1
View File
@@ -181,7 +181,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs. // Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container { func (m *Ci) toolchain() *dagger.Container {
return dag.Container(). return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6"). From("ghcr.io/cirruslabs/flutter:3.44.0").
WithExec([]string{"apt-get", "-qq", "update"}). WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}). WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}). WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
+12
View File
@@ -4,6 +4,18 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26) ## Tasks (2026-05-26)
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep - **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 34; const int dbSchemaVersion = 36;
+9 -1
View File
@@ -1,6 +1,14 @@
enum MenuPosition { bottom, top } enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences { class UserPreferences {
const UserPreferences({this.menuPosition = MenuPosition.bottom}); const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition; final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
} }
@@ -3,4 +3,6 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository { abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences(); Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position); Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
} }
@@ -92,8 +92,9 @@ class ShareEncryptionService {
) { ) {
if (!s.startsWith(_pubKeyPrefix)) return null; if (!s.startsWith(_pubKeyPrefix)) return null;
try { try {
final data = final data = Uint8List.fromList(
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length))); base64.decode(s.substring(_pubKeyPrefix.length)),
);
if (data.length != _keyIdLen + _pubKeyLen) return null; if (data.length != _keyIdLen + _pubKeyLen) return null;
return ( return (
keyId: data.sublist(0, _keyIdLen), keyId: data.sublist(0, _keyIdLen),
+3 -2
View File
@@ -108,8 +108,9 @@ class SieveInterpreter {
} }
bool _globMatch(String value, String pattern) { bool _globMatch(String value, String pattern) {
final regexStr = final regexStr = RegExp.escape(
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); pattern,
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value); return RegExp('^$regexStr\$').hasMatch(value);
} }
+3 -9
View File
@@ -466,9 +466,7 @@ class _Scanner {
String readTaggedArg() { String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord(); if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException( throw SieveParseException('Expected tagged argument at position $_pos');
'Expected tagged argument at position $_pos',
);
} }
String? peekSizeUnit() { String? peekSizeUnit() {
@@ -480,9 +478,7 @@ class _Scanner {
String readDigits() { String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) { if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException( throw SieveParseException('Expected number at position $_pos');
'Expected number at position $_pos',
);
} }
final start = _pos; final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) { while (!isAtEnd && _isDigit(_src[_pos])) {
@@ -493,9 +489,7 @@ class _Scanner {
String readQuotedString() { String readQuotedString() {
if (_src[_pos] != '"') { if (_src[_pos] != '"') {
throw SieveParseException( throw SieveParseException('Expected " at position $_pos');
'Expected " at position $_pos',
);
} }
_pos++; // skip opening quote _pos++; // skip opening quote
final buf = StringBuffer(); final buf = StringBuffer();
+1 -4
View File
@@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"') .replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
.replaceAll("src='cid:$bareCid'", "src='$dataUri'") .replaceAll("src='cid:$bareCid'", "src='$dataUri'")
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"') .replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
.replaceAll( .replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
"src='cid:${bareCid.toLowerCase()}'",
"src='$dataUri'",
);
} }
return result; return result;
} }
+18
View File
@@ -313,6 +313,12 @@ class UserPreferences extends Table {
IntColumn get id => integer()(); IntColumn get id => integer()();
// 'bottom' (default) | 'top' // 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))(); TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
@@ -593,6 +599,18 @@ class AppDatabase extends _$AppDatabase {
if (from < 34) { if (from < 34) {
await m.createTable(userPreferences); await m.createTable(userPreferences);
} }
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
}, },
); );
} }
+11 -16
View File
@@ -9,8 +9,9 @@ class LocalSieveRepository {
final AppDatabase _db; final AppDatabase _db;
Future<List<SieveScript>> listScripts(String accountId) async { Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select(_db.localSieveScripts) final rows = await (_db.select(
..where((t) => t.accountId.equals(accountId))) _db.localSieveScripts,
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
return rows return rows
.map( .map(
@@ -26,10 +27,9 @@ class LocalSieveRepository {
Future<String> getScriptContent(String accountId, String blobId) async { Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId); final rowId = int.parse(blobId);
final row = await (_db.select(_db.localSieveScripts) final row = await (_db.select(
..where( _db.localSieveScripts,
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId'); if (row == null) throw Exception('Local script not found: $blobId');
return row.content; return row.content;
@@ -44,9 +44,7 @@ class LocalSieveRepository {
if (id != null) { if (id != null) {
final rowId = int.parse(id); final rowId = int.parse(id);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where( ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write( .write(
LocalSieveScriptsCompanion( LocalSieveScriptsCompanion(
name: Value(name), name: Value(name),
@@ -78,10 +76,9 @@ class LocalSieveRepository {
Future<void> deleteScript(String accountId, String scriptId) async { Future<void> deleteScript(String accountId, String scriptId) async {
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.delete(_db.localSieveScripts) await (_db.delete(
..where( _db.localSieveScripts,
(t) => t.id.equals(rowId) & t.accountId.equals(accountId), )..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
))
.go(); .go();
} }
@@ -92,9 +89,7 @@ class LocalSieveRepository {
.write(const LocalSieveScriptsCompanion(isActive: Value(false))); .write(const LocalSieveScriptsCompanion(isActive: Value(false)));
final rowId = int.parse(scriptId); final rowId = int.parse(scriptId);
await (_db.update(_db.localSieveScripts) await (_db.update(_db.localSieveScripts)
..where( ..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(const LocalSieveScriptsCompanion(isActive: Value(true))); .write(const LocalSieveScriptsCompanion(isActive: Value(true)));
}); });
} }
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl( DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
this._db, : _imapConnect = imapConnect;
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -124,10 +121,7 @@ class DraftRepositoryImpl implements DraftRepository {
} }
} }
Future<void> _syncWithServer( Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
imap.ImapClient client,
String accountId,
) async {
// Create/select the Drafts folder. // Create/select the Drafts folder.
try { try {
await client.createMailbox('Drafts'); await client.createMailbox('Drafts');
@@ -162,8 +156,9 @@ class DraftRepositoryImpl implements DraftRepository {
? uidList.first.toString() ? uidList.first.toString()
: null; : null;
if (uid != null) { if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))) await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
.write(DraftsCompanion(imapServerId: Value(uid))); DraftsCompanion(imapServerId: Value(uid)),
);
} }
} }
@@ -156,6 +156,7 @@ class EmailRepositoryImpl implements EmailRepository {
return; return;
} }
if (threadEmails.isEmpty) return;
final latest = threadEmails.last; final latest = threadEmails.last;
// Collect unique participants across the whole thread. // Collect unique participants across the whole thread.
@@ -237,7 +238,12 @@ class EmailRepositoryImpl implements EmailRepository {
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
final msg = fetch.messages.first; final msg = fetch.messages.firstOrNull;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final textBody = msg.decodeTextPlainPart(); final textBody = msg.decodeTextPlainPart();
final rawHtml = msg.decodeTextHtmlPart(); final rawHtml = msg.decodeTextHtmlPart();
final htmlBody = final htmlBody =
@@ -325,13 +331,7 @@ class EmailRepositoryImpl implements EmailRepository {
], ],
'fetchHTMLBodyValues': true, 'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true, 'fetchTextBodyValues': true,
'bodyProperties': [ 'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
'partId',
'type',
'name',
'size',
'subParts',
],
}, },
'0', '0',
], ],
@@ -1949,8 +1949,9 @@ class EmailRepositoryImpl implements EmailRepository {
.getSingleOrNull(); .getSingleOrNull();
final inboxPath = inboxMailbox?.path ?? 'INBOX'; final inboxPath = inboxMailbox?.path ?? 'INBOX';
final alreadyApplied = await (_db.select(_db.localSieveApplied) final alreadyApplied = await (_db.select(
..where((t) => t.accountId.equals(accountId))) _db.localSieveApplied,
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
@@ -2050,7 +2051,9 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(1)) ..limit(1))
.getSingleOrNull(); .getSingleOrNull();
if (destMailbox == null) { if (destMailbox == null) {
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}'); log(
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
);
return; return;
} }
destPath = destMailbox.path; destPath = destMailbox.path;
@@ -2808,11 +2811,13 @@ class EmailRepositoryImpl implements EmailRepository {
// Content-Transfer-Encoding) and getPart() can decode the part correctly. // Content-Transfer-Encoding) and getPart() can decode the part correctly.
// A partial BODY.PEEK[n] fetch omits those headers, causing // A partial BODY.PEEK[n] fetch omits those headers, causing
// decodeContentBinary() to return raw base64 instead of decoded bytes. // decodeContentBinary() to return raw base64 instead of decoded bytes.
final fetch = await client.uidFetchMessage( final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
emailRow.uid, final msg = fetch.messages.firstOrNull;
'BODY.PEEK[]', if (msg == null) {
); throw StateError(
final msg = fetch.messages.first; 'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final part = msg.getPart(attachment.fetchPartId) ?? msg; final part = msg.getPart(attachment.fetchPartId) ?? msg;
final bytes = part.decodeContentBinary(); final bytes = part.decodeContentBinary();
if (bytes == null) { if (bytes == null) {
@@ -2874,11 +2879,14 @@ class EmailRepositoryImpl implements EmailRepository {
); );
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage( final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
emailRow.uid, final msg = fetch.messages.firstOrNull;
'BODY.PEEK[]', if (msg == null) {
); throw StateError(
return fetch.messages.first.renderMessage(); 'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
return msg.renderMessage();
} finally { } finally {
await client.logout(); await client.logout();
} }
@@ -3252,14 +3260,17 @@ class EmailRepositoryImpl implements EmailRepository {
await _db.customStatement('PRAGMA foreign_keys = OFF'); await _db.customStatement('PRAGMA foreign_keys = OFF');
try { try {
await _db.transaction(() async { await _db.transaction(() async {
await (_db.delete(_db.emails) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.emails,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete(_db.pendingChanges) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.pendingChanges,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete(_db.syncStates) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.syncStates,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
}); });
} finally { } finally {
@@ -82,8 +82,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
// Pre-load existing DB roles so we can preserve manually-set roles for // Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute. // folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(_db.mailboxes) final existingRows = await (_db.select(
..where((t) => t.accountId.equals(account.id))) _db.mailboxes,
)..where((t) => t.accountId.equals(account.id)))
.get(); .get();
final existingRoles = {for (final r in existingRows) r.id: r.role}; final existingRoles = {for (final r in existingRows) r.id: r.role};
@@ -320,8 +321,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete(_db.mailboxes) await (_db.delete(
..where((t) => t.accountId.equals(accountId))) _db.mailboxes,
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@@ -367,7 +369,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role), role: Value(role),
), ),
); );
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id))) final row = await (_db.select(
_db.mailboxes,
)..where((t) => t.id.equals(id)))
.getSingle(); .getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -419,8 +423,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role), role: Value(role),
), ),
); );
final row = await (_db.select(_db.mailboxes) final row = await (_db.select(
..where((t) => t.id.equals(dbId))) _db.mailboxes,
)..where((t) => t.id.equals(dbId)))
.getSingle(); .getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -24,8 +24,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
await _db.transaction(() async { await _db.transaction(() async {
// Remove existing entry for same query (deduplication). // Remove existing entry for same query (deduplication).
await (_db.delete(_db.searchHistoryEntries) await (_db.delete(
..where((t) => t.query.equals(trimmed))) _db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed)))
.go(); .go();
await _db.into(_db.searchHistoryEntries).insert( await _db.into(_db.searchHistoryEntries).insert(
@@ -43,8 +44,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
.get(); .get();
if (keepIds.isNotEmpty) { if (keepIds.isNotEmpty) {
await (_db.delete(_db.searchHistoryEntries) await (_db.delete(
..where((t) => t.id.isNotIn(keepIds))) _db.searchHistoryEntries,
)..where((t) => t.id.isNotIn(keepIds)))
.go(); .go();
} }
}); });
@@ -40,8 +40,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
await _pruneExpired(); await _pruneExpired();
final keyIdHex = _hex(keyId); final keyIdHex = _hex(keyId);
final row = await (_db.select(_db.shareKeys) final row = await (_db.select(
..where((t) => t.id.equals(keyIdHex))) _db.shareKeys,
)..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) return null; if (row == null) return null;
@@ -55,10 +56,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
} }
Future<void> _pruneExpired() async { Future<void> _pruneExpired() async {
await (_db.delete(_db.shareKeys) await (_db.delete(
..where( _db.shareKeys,
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()), )..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
))
.go(); .go();
} }
@@ -11,7 +11,9 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
@override @override
Stream<pref.UserPreferences> observePreferences() { Stream<pref.UserPreferences> observePreferences() {
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId))) return (_db.select(
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull() .watchSingleOrNull()
.map(_rowToModel); .map(_rowToModel);
} }
@@ -26,6 +28,28 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
); );
} }
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) { static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences(); if (row == null) return const pref.UserPreferences();
return pref.UserPreferences( return pref.UserPreferences(
@@ -33,6 +57,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
(e) => e.name == row.menuPosition, (e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom, orElse: () => pref.MenuPosition.bottom,
), ),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
); );
} }
} }
+16 -10
View File
@@ -101,8 +101,9 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider = final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
Provider<SearchHistoryRepository>((ref) { ref,
) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -135,8 +136,10 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider = final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
StreamProvider.autoDispose.family<bool, String>((ref, accountId) { ref,
accountId,
) {
return ref.watch(syncManagerProvider).watchSyncing(accountId); return ref.watch(syncManagerProvider).watchSyncing(accountId);
}); });
@@ -185,8 +188,9 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
final undoServiceProvider = final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new); UndoService.new,
);
/// Loads email header + body and marks the email as seen. /// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree. /// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
@@ -232,12 +236,14 @@ final accountConnectionStatusProvider =
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider = final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
Provider<UserPreferencesRepository>((ref) { ref,
) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider)); return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
}); });
final userPreferencesProvider = final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
StreamProvider.autoDispose<UserPreferences>((ref) { ref,
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences(); return ref.watch(userPreferencesRepositoryProvider).observePreferences();
}); });
+9 -7
View File
@@ -72,8 +72,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Future<void> _launchUrl(BuildContext context, Uri url) async { Future<void> _launchUrl(BuildContext context, Uri url) async {
try { try {
final launched = final launched = await launchUrl(
await launchUrl(url, mode: LaunchMode.externalApplication); url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -121,8 +123,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
); );
try { try {
final launched = final launched = await launchUrl(
await launchUrl(url, mode: LaunchMode.externalApplication); url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -176,9 +180,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited( unawaited(_launchUrl(context, Uri.parse(href)));
_launchUrl(context, Uri.parse(href)),
);
} }
}, },
); );
+73 -70
View File
@@ -120,15 +120,76 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id)); final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP'; final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile( return Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Column( ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
Text('${account.email}\n$typeLabel'), subtitle: Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4), isThreeLine: true,
health.when( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) { data: (h) {
if (h == null) return const Text('Sync health: Not verified yet'); if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
@@ -141,7 +202,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Expanded(
child: Text( child: Text(
h.isHealthy h.isHealthy
? 'Healthy' ? 'Healthy'
@@ -155,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'), loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'), error: (e, _) => Text('Sync health error: $e'),
), ),
], ),
), ],
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
); );
} }
+1 -5
View File
@@ -219,11 +219,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
), ),
), ),
_Step.done => const Center( _Step.done => const Center(
child: Icon( child: Icon(Icons.check_circle, size: 64, color: Colors.green),
Icons.check_circle,
size: 64,
color: Colors.green,
),
), ),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
+2 -7
View File
@@ -158,10 +158,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
for (final account in selected) { for (final account in selected) {
final password = await repo.getPassword(account.id); final password = await repo.getPassword(account.id);
payloads.add( payloads.add(
AccountPayload( AccountPayload(accountJson: account.toJson(), password: password),
accountJson: account.toJson(),
password: password,
),
); );
} }
@@ -361,9 +358,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!))); unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text('Encrypted code copied to clipboard'),
'Encrypted code copied to clipboard',
),
), ),
); );
}, },
+3 -2
View File
@@ -12,8 +12,9 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')), appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: future: DefaultAssetBundle.of(
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'), context,
).loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
+3 -9
View File
@@ -194,9 +194,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Failed to open file: $e'), content: Text('Failed to open file: $e'),
@@ -213,9 +211,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Select an account first'), content: Text('Select an account first'),
@@ -255,9 +251,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
content: Text('Send failed: $e'), content: Text('Send failed: $e'),
+3 -3
View File
@@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget {
builder: (context, snapshot) => Text( builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode' 'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}', '${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(
color: Colors.grey[600], context,
), ).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
+3 -2
View File
@@ -54,8 +54,9 @@ Future<Mailbox?> resolveMailboxByRole(
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
for (final m for (final m in mailboxes.where(
in mailboxes.where((m) => m.path != currentMailboxPath)) (m) => m.path != currentMailboxPath,
))
ListTile( ListTile(
leading: const Icon(Icons.folder_outlined), leading: const Icon(Icons.folder_outlined),
title: Text(m.name), title: Text(m.name),
+77 -60
View File
@@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.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/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
@@ -71,18 +72,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited( unawaited(_replyWithRecipientDialog(context, header, body));
_replyWithRecipientDialog(context, header, body),
);
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
}, },
), ),
IconButton( IconButton(
@@ -98,6 +88,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
tooltip: 'Delete', tooltip: 'Delete',
onPressed: () async { onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId); final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) { if (header != null) {
@@ -116,28 +107,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
}, },
), ),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton( IconButton(
icon: Icon( icon: Icon(
_isFlagged ? Icons.star : Icons.star_border, _isFlagged ? Icons.star : Icons.star_border,
@@ -152,10 +124,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ 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( const PopupMenuItem(
value: 'mark_unread', value: 'mark_unread',
child: Text('Mark as unread'), child: Text('Mark as unread'),
), ),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -164,15 +141,21 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure', value: 'structure',
child: Text('Show Mail Structure'), child: Text('Show Mail Structure'),
), ),
const PopupMenuItem( const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
value: 'rfc',
child: Text('Show Raw Email'),
),
], ],
onSelected: (value) async { onSelected: (value) async {
if (value == 'mark_unread') { if (value == 'forward' && header != null) {
unawaited(_forward(context, header, body));
} else if (value == 'move' && header != null) {
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); await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) { } else if (value == 'headers' && body != null) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
@@ -252,6 +235,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(widget.emailId),
);
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async { Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename)); setState(() => _downloading.add(att.filename));
try { try {
@@ -403,6 +420,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
Future<void> _archive(BuildContext context, Email header) async { Future<void> _archive(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole( final mailbox = await resolveMailboxByRole(
context, context,
ref.read(mailboxRepositoryProvider), ref.read(mailboxRepositoryProvider),
@@ -432,10 +452,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _markAsSpam(BuildContext context, Email header) async { Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole( final mailbox = await resolveMailboxByRole(
context, context,
ref.read(mailboxRepositoryProvider), ref.read(mailboxRepositoryProvider),
@@ -465,7 +488,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _forward( Future<void> _forward(
@@ -481,15 +504,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: { extra: {'prefillSubject': subject, 'prefillBody': quoted},
'prefillSubject': subject,
'prefillBody': quoted,
},
), ),
); );
} }
Future<void> _moveTo(BuildContext context, Email header) async { Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first; await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -538,10 +560,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _snooze(BuildContext context, Email header) async { Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>( final until = await showModalBottomSheet<DateTime>(
context: context, context: context,
builder: (ctx) => const SnoozePicker(), builder: (ctx) => const SnoozePicker(),
@@ -569,7 +594,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
); );
context.pop(); _navigateTo(context, header, nextEmailId);
} }
} }
@@ -581,9 +606,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.fetchRawRfc822(widget.emailId); .fetchRawRfc822(widget.emailId);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Failed to fetch raw email: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
return; return;
} }
@@ -748,9 +773,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text( content: Text('Structure not available. Try re-syncing the email.'),
'Structure not available. Try re-syncing the email.',
),
), ),
); );
return; return;
@@ -859,14 +882,8 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
SegmentedButton<_Placement>( SegmentedButton<_Placement>(
showSelectedIcon: false, showSelectedIcon: false,
segments: const [ segments: const [
ButtonSegment( ButtonSegment(value: _Placement.to, label: Text('To')),
value: _Placement.to, ButtonSegment(value: _Placement.cc, label: Text('Cc')),
label: Text('To'),
),
ButtonSegment(
value: _Placement.cc,
label: Text('Cc'),
),
ButtonSegment( ButtonSegment(
value: _Placement.skip, value: _Placement.skip,
label: Text('Skip'), label: Text('Skip'),
+3 -8
View File
@@ -381,11 +381,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
return MaterialBanner( return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text( content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon( leading: Icon(
Icons.sync_problem, Icons.sync_problem,
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
@@ -399,9 +395,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: const Text('Retry'), child: const Text('Retry'),
), ),
TextButton( TextButton(
onPressed: () => context.push( onPressed: () =>
'/accounts/${widget.accountId}/sync-log', context.push('/accounts/${widget.accountId}/sync-log'),
),
child: const Text('View log'), child: const Text('View log'),
), ),
TextButton( TextButton(
+3 -2
View File
@@ -10,8 +10,9 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
FutureProvider.autoDispose<List<String>>((ref) async { ref,
) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches(); return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
}); });
+1 -3
View File
@@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
), ),
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+34 -1
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Thread')), appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
} }
class _EmailMessageCard extends ConsumerStatefulWidget { class _EmailMessageCard extends ConsumerStatefulWidget {
@@ -141,6 +163,17 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
FutureBuilder<EmailBody>( FutureBuilder<EmailBody>(
future: _bodyFuture, future: _bodyFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Failed to load email: ${snapshot.error}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
);
}
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center( return const Center(
child: Padding( child: Padding(
+1 -3
View File
@@ -84,9 +84,7 @@ class _UndoActionTile extends ConsumerWidget {
.read(undoServiceProvider.notifier) .read(undoServiceProvider.notifier)
.undo(actionId: action.id); .undo(actionId: action.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Action undone.'), content: Text('Action undone.'),
@@ -59,6 +59,78 @@ class UserPreferencesScreen extends ConsumerWidget {
], ],
), ),
), ),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text('Show the back button in the top bar.'),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text('Show the next message in the mailbox.'),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text('Return to the message list.'),
value: AfterMailViewAction.showMailbox,
),
],
),
),
], ],
), ),
), ),
+3 -2
View File
@@ -26,8 +26,9 @@ String buildAboutMarkdown({
final osName = _capitalize(Platform.operatingSystem); final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark; final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString(); final locale = Localizations.localeOf(context).toString();
final textScale = final textScale = MediaQuery.of(
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1); context,
).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n' ? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
+17 -11
View File
@@ -111,12 +111,16 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
Future<void> _measureHeight(String _) async { Future<void> _measureHeight(String _) async {
final result = await _controller!.runJavaScriptReturningResult( try {
'document.documentElement.scrollHeight', final result = await _controller!.runJavaScriptReturningResult(
); 'document.documentElement.scrollHeight',
final h = double.tryParse(result.toString()); );
if (h != null && h > 0 && mounted) { final h = double.tryParse(result.toString());
setState(() => _height = h); if (h != null && h > 0 && mounted) {
setState(() => _height = h);
}
} catch (_) {
// WebView not ready yet; height stays at default
} }
} }
@@ -187,12 +191,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final launched = final launched = await launchUrl(
await launchUrl(uri, mode: LaunchMode.externalApplication); uri,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) { if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Could not open: $url')), context,
); ).showSnackBar(SnackBar(content: Text('Could not open: $url')));
} }
} }
} }
+8 -8
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.31.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.17"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -33,7 +33,7 @@ dependencies:
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
# Date formatting # Date formatting
intl: any intl: ^0.20.2
# File picking (compose attachments) and opening downloaded attachments # File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4 file_picker: ^12.0.0-beta.4
+1 -1
View File
@@ -5,7 +5,7 @@
], ],
"labels": ["dependencies"], "labels": ["dependencies"],
"github-actions": { "github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"] "enabled": false
}, },
"packageRules": [ "packageRules": [
{ {
File diff suppressed because it is too large Load Diff
+62 -88
View File
@@ -1,102 +1,76 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
set -euo pipefail set -euo pipefail
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: DAGGER_STUNNEL_URL must be set." echo "Error: SOPS_AGE_KEY must be set."
exit 1 exit 1
fi fi
# Parse host and port (e.g., example.com:8774 or just example.com) echo "Decrypting secrets with SOPS..."
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1) export SOPS_AGE_KEY="$SOPS_AGE_KEY"
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2) SECRETS_JSON=$(mktemp)
if [ "$host" == "$port" ]; then trap "rm -f $SECRETS_JSON" EXIT
port="8774"
fi
MAX_PROBE_ATTEMPTS=5 sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
PROBE_DELAY=30
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
echo "Found active server on $host:$port"
break
fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0
fi
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
sleep $PROBE_DELAY
done
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS) DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
echo "Trying plain TCP Dagger connection at tcp://$host:$port..." DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \ # Export all CI secrets to the GitHub Actions environment so subsequent steps
timeout 8 dagger version >/dev/null 2>&1; then # can use them without referencing Forgejo secrets directly.
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed." export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
if [ -n "${GITHUB_ENV:-}" ]; then if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" # Use heredoc syntax for multiline-safe export
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV" {
else printf '%s<<__EOF__\n' "$name"
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" printf '%s\n' "$value"
export _DAGGER_RUNNER_HOST="tcp://$host:$port" printf '__EOF__\n'
echo "Dagger configured at tcp://$host:$port (plain TCP)" } >> "$GITHUB_ENV"
fi fi
exit 0 printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
}
export_secret "SSH_PRIVATE_KEY"
export_secret "SSH_KNOWN_HOSTS"
export_secret "SSH_USER"
export_secret "SSH_HOST"
export_secret "WEBSITE_SSH_HOST"
export_secret "PLAY_STORE_CONFIG_JSON"
export_secret "ANDROID_KEYSTORE_BASE64"
export_secret "ANDROID_KEYSTORE_PASSWORD"
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
# Add remote host to known_hosts
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
fi fi
echo "Plain TCP connection not available; trying TLS stunnel..."
# 2b. Setup TLS credentials (passed as env vars from secrets) # Verify the connection
mkdir -p /tmp/dagger-tls echo "Verifying connection to Dagger engine via SSH tunnel..."
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt # Use a simple command that doesn't require complex GraphQL operations.
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
chmod 600 /tmp/dagger-tls/client.key # Debug
ps aux | grep ssh
# 3. Configure and start stunnel
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
cat << EOF > "$STUNNEL_CONF"
client = yes
foreground = yes
pid = /tmp/stunnel.pid
debug = warning
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
socket = r:SO_KEEPALIVE=1
socket = r:TCP_KEEPIDLE=10
socket = r:TCP_KEEPINTVL=5
socket = r:TCP_KEEPCNT=3
[dagger]
accept = 127.0.0.1:1774
connect = $host:$port
CAfile = /tmp/dagger-tls/ca.crt
cert = /tmp/dagger-tls/client.crt
key = /tmp/dagger-tls/client.key
verifyChain = yes
EOF
# Start stunnel in the background
stunnel "$STUNNEL_CONF" &
TUNNEL_PID=$!
# Give it a moment to establish
sleep 2
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
echo "Error: stunnel failed to start"
exit 1 exit 1
fi fi
echo "Dagger connection verified successfully."
# 4. Export environment for subsequent CI steps
if [ -n "${GITHUB_ENV:-}" ]; then
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
echo "Tunnel established. Dagger is configured to use the remote engine."
else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
fi
-938
View File
@@ -1,938 +0,0 @@
#!/usr/bin/env python3
"""Tests for agent_loop.py."""
import contextlib
import io
import json
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import sys
sys.path.insert(0, str(Path(__file__).parent))
import agent_loop
class TestUrlHelpers(unittest.TestCase):
def test_issue_url(self):
url = agent_loop._issue_url(128)
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
def test_ci_run_url(self):
url = agent_loop._ci_run_url(4145144)
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
class TestStateFile(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
self._tmp.close()
self._orig = agent_loop.STATE_FILE
agent_loop.STATE_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no state file.
def tearDown(self):
agent_loop.STATE_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_write_state_stores_pid(self):
agent_loop._write_state(12345, 91, "issue")
data = json.loads(Path(self._tmp.name).read_text())
self.assertEqual(data["pid"], 12345)
self.assertNotIn("tmux_session", data)
def test_write_state_stores_issue_and_kind(self):
agent_loop._write_state(99, 7, "ci-fix")
data = json.loads(Path(self._tmp.name).read_text())
self.assertEqual(data["issue"], 7)
self.assertEqual(data["type"], "ci-fix")
self.assertIn("started_at", data)
def test_read_state_returns_none_when_missing(self):
self.assertIsNone(agent_loop._read_state())
def test_read_and_write_roundtrip(self):
agent_loop._write_state(42, 10, "issue")
state = agent_loop._read_state()
self.assertIsNotNone(state)
self.assertEqual(state["pid"], 42)
self.assertEqual(state["issue"], 10)
def test_clear_state_removes_file(self):
agent_loop._write_state(1, None, "ci-fix")
agent_loop._clear_state()
self.assertIsNone(agent_loop._read_state())
def test_write_state_stores_issue_title(self):
agent_loop._write_state(42, 10, "issue", "My Test Issue")
data = json.loads(Path(self._tmp.name).read_text())
self.assertEqual(data["issue_title"], "My Test Issue")
def test_write_state_omits_issue_title_when_none(self):
agent_loop._write_state(42, None, "ci-fix")
data = json.loads(Path(self._tmp.name).read_text())
self.assertNotIn("issue_title", data)
class TestAgentAlive(unittest.TestCase):
def test_own_pid_is_alive(self):
self.assertTrue(agent_loop._agent_alive({"pid": os.getpid()}))
def test_nonexistent_pid_is_dead(self):
self.assertFalse(agent_loop._agent_alive({"pid": 999999999}))
def test_missing_pid_returns_false(self):
self.assertFalse(agent_loop._agent_alive({}))
self.assertFalse(agent_loop._agent_alive({"pid": None}))
class TestIsClaudeProcess(unittest.TestCase):
def test_returns_true_for_claude_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_true_for_node_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_false_for_other_process(self):
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
self.assertFalse(agent_loop._is_claude_process(1234))
def test_returns_false_when_proc_missing(self):
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
self.assertFalse(agent_loop._is_claude_process(1234))
class TestKillAgent(unittest.TestCase):
def test_kill_sends_sigkill(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
def test_kill_ignores_missing_process(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
def test_kill_noop_when_no_pid(self):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({})
mock_kill.assert_not_called()
def test_kill_skips_recycled_pid(self):
with patch("agent_loop._is_claude_process", return_value=False):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_not_called()
class TestStartAgent(unittest.TestCase):
def _make_mock_proc(self, pid=42):
proc = MagicMock()
proc.pid = pid
proc.stdin = io.BytesIO()
return proc
def test_start_agent_returns_pid(self):
mock_proc = self._make_mock_proc(pid=42)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc):
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
result = agent_loop._start_agent("do something", "issue-99")
self.assertEqual(result, 42)
def test_start_agent_uses_popen_not_tmux(self):
mock_proc = self._make_mock_proc(pid=7)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
with patch("agent_loop.subprocess.run") as mock_run:
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
agent_loop._start_agent("prompt", "ci-fix")
mock_popen.assert_called_once()
mock_run.assert_not_called()
def test_start_agent_passes_session_name_to_claude(self):
mock_proc = self._make_mock_proc(pid=7)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
agent_loop._start_agent("prompt", "issue-55")
cmd = mock_popen.call_args[0][0]
self.assertIn("issue-55", cmd)
self.assertIn("claude", cmd[0])
def test_start_agent_uses_start_new_session(self):
mock_proc = self._make_mock_proc(pid=7)
with tempfile.TemporaryDirectory() as tmpdir:
with patch("agent_loop.subprocess.Popen", return_value=mock_proc) as mock_popen:
with patch.object(agent_loop.Path, "home", return_value=Path(tmpdir)):
agent_loop._start_agent("prompt", "issue-55")
kwargs = mock_popen.call_args[1]
self.assertTrue(kwargs.get("start_new_session"))
class TestMain(unittest.TestCase):
"""Tests for the main() flow."""
def _make_mock_proc(self, pid=42):
proc = MagicMock()
proc.pid = pid
proc.stdin = io.BytesIO()
return proc
def _make_issue(self, number=10, title="Do something"):
return {"number": number, "title": title, "body": "", "labels": []}
def test_sets_in_progress_before_starting_agent(self):
"""_set_labels(InProgress) must be called before _start_agent."""
call_order = []
mock_proc = self._make_mock_proc(pid=55)
def fake_set_labels(issue, add, remove):
call_order.append(("set_labels", add, remove))
def fake_start_agent(prompt, session_name):
call_order.append(("start_agent", session_name))
return 55
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
patch("agent_loop._write_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
labels_idx = next(
i for i, c in enumerate(call_order) if c[0] == "set_labels"
)
agent_idx = next(
i for i, c in enumerate(call_order) if c[0] == "start_agent"
)
self.assertLess(labels_idx, agent_idx,
"_set_labels must be called before _start_agent")
def test_sets_in_progress_label_and_removes_ready(self):
"""The InProgress label is added and the Ready label is removed."""
captured = {}
def fake_set_labels(issue, add, remove):
captured["add"] = add
captured["remove"] = remove
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
patch("agent_loop._start_agent", return_value=99), \
patch("agent_loop._write_state"):
agent_loop._run_loop()
self.assertIn(agent_loop.LABEL_IN_PROGRESS, captured.get("add", []))
self.assertIn(agent_loop.LABEL_READY, captured.get("remove", []))
def test_no_ready_issues_does_nothing(self):
"""main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._start_agent") as mock_start:
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_labels.assert_not_called()
mock_start.assert_not_called()
def test_prompt_does_not_tell_agent_to_close_issue(self):
"""Agents must not close issues; the loop handles closing after CI passes."""
captured_prompt = {}
def fake_start_agent(prompt, session_name):
captured_prompt["prompt"] = prompt
return 77
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", side_effect=fake_start_agent), \
patch("agent_loop._write_state"):
agent_loop._run_loop()
prompt = captured_prompt.get("prompt", "")
# "do NOT close the issue" (blocker instruction) is fine; what must be
# absent is any affirmative instruction to close on completion.
self.assertNotIn("close the issue and stop", prompt.lower())
class TestPendingCi(unittest.TestCase):
"""Tests for the pending-CI state: issue closed only after CI passes."""
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
return {
"pid": 999999999, # non-existent PID
"issue": issue,
"started_at": "2026-01-01T00:00:00+00:00",
"type": kind,
}
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
def _find_pr_open(self, branch, state="open"):
if state == "open":
return self._open_pr(branch)
return None
def test_closes_issue_when_ci_passes_after_agent_finishes(self):
"""After issue agent finishes, loop merges the PR and closes the issue once CI is green."""
# First call: PR found open. Second call (post-merge verification): PR closed.
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(5)
mock_close.assert_called_once_with(10)
def test_ci_passed_output_includes_ci_run_url(self):
"""'CI passed' line includes the CI run URL when a run is available."""
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._close_issue"), \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", output)
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/10", output)
def test_already_merged_pr_closes_issue_without_ci_url(self):
"""When the PR was already merged, the issue is closed and no CI run URL appears."""
def find_pr(branch, state="open"):
if state == "closed":
return {"number": 5, "merged": True}
return None
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=find_pr), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"), \
contextlib.redirect_stdout(buf):
result = agent_loop._run_loop()
output = buf.getvalue()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
self.assertIn("already merged", output)
self.assertNotIn("/actions/runs/", output)
def test_no_pr_found_sets_question_label(self):
"""When no open or merged PR exists for the pending branch, set State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
mock_labels.assert_called_once_with(
10,
add=[agent_loop.LABEL_QUESTION],
remove=[agent_loop.LABEL_IN_PROGRESS],
)
mock_comment.assert_called_once()
self.assertIn("issue-10-fix", mock_comment.call_args[0][1])
def test_does_not_close_issue_when_ci_fails(self):
"""After issue agent finishes, loop must NOT close the issue if CI failed on PR branch."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._start_agent", return_value=55), \
patch("agent_loop._write_state"), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
def test_saves_pending_ci_state_while_ci_running(self):
"""When CI is still running on PR branch after agent finishes, pending issue is preserved."""
written = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written["pid"] = pid
written["issue"] = issue
written["kind"] = kind
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "running"}), \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
self.assertEqual(written.get("issue"), 10)
self.assertEqual(written.get("kind"), "pending-ci")
self.assertIsNone(written.get("pid"))
def test_ci_fix_preserves_pending_issue_in_state(self):
"""When CI fails on PR branch after agent finishes, ci-fix state includes the pending issue."""
written = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written["pid"] = pid
written["issue"] = issue
written["kind"] = kind
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "failure"}), \
patch("agent_loop._start_agent", return_value=55), \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
self.assertEqual(written.get("issue"), 10)
self.assertEqual(written.get("kind"), "ci-fix")
def test_closes_issue_after_ci_fix_and_ci_passes(self):
"""After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(5)
mock_close.assert_called_once_with(10)
def test_no_pending_issue_ci_fix_without_issue(self):
"""ci-fix for a manual push (no pending issue) does not try to close anything."""
with patch("agent_loop._read_state", return_value={
"pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00",
"type": "ci-fix",
}), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
class TestOutputFormat(unittest.TestCase):
"""Verify output format: no [agent_loop] prefix, URLs in output."""
def test_output_starts_with_header(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
first_line = buf.getvalue().splitlines()[0]
self.assertTrue(first_line.startswith("---------------------- Starting "),
f"Unexpected first line: {first_line!r}")
def test_no_agent_loop_prefix_in_output(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertNotIn("[agent_loop]", buf.getvalue())
def test_ci_run_output_contains_url(self):
run = {"id": 4145144, "status": "running"}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
buf.getvalue())
def test_issue_output_contains_url_and_title(self):
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \
patch("agent_loop._start_agent", return_value=99), \
patch("agent_loop._write_state"), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
self.assertIn("Fix something", output)
class TestLatestMainCiRun(unittest.TestCase):
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
def _ci_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
"status": status, "id": run_id}
def _deploy_run(self, run_id, status="success"):
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
"status": status, "id": run_id}
def test_skips_deploy_run_returns_ci_run(self):
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
# must be excluded by workflow_id filter.
runs = [self._deploy_run(1), self._ci_run(2)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_returns_none_when_only_deploy_runs_exist(self):
runs = [self._deploy_run(1)]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_none_when_only_schedule_runs_exist(self):
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
"status": "success", "id": 1}]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNone(result)
def test_returns_ci_push_to_main_run(self):
runs = [self._ci_run(42, status="running")]
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_main_ci_run()
self.assertIsNotNone(result)
self.assertEqual(result["id"], 42)
class TestLatestCiRunForBranch(unittest.TestCase):
"""Tests for _latest_ci_run_for_branch — Forgejo API field mapping."""
def _make_pr_run(self, branch: str, status: str = "success") -> dict:
payload = json.dumps({"pull_request": {"head": {"ref": branch}}})
return {"event": "pull_request", "event_payload": payload, "status": status, "id": 1}
def _make_push_run(self, prettyref: str, status: str = "success") -> dict:
return {"event": "push", "prettyref": prettyref, "status": status, "id": 2}
def _mock_tea_runs(self, runs):
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}) as m:
yield m
def test_pr_event_matches_via_event_payload(self):
run = self._make_pr_run("issue-166-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
self.assertEqual(result["id"], 1)
def test_pr_event_does_not_match_wrong_branch(self):
run = self._make_pr_run("issue-99-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_push_event_matches_via_prettyref(self):
run = self._make_push_run("issue-166-fix")
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
self.assertEqual(result["id"], 2)
def test_push_event_prettyref_pr_number_does_not_match_branch(self):
# Forgejo sets prettyref="#169" for PR runs — must not match branch name.
run = {"event": "push", "prettyref": "#169", "status": "success", "id": 3}
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_head_branch_field_absent_still_works(self):
# Regression: the old code used run.get("head_branch") which is absent in Forgejo.
run = self._make_pr_run("issue-166-fix")
self.assertNotIn("head_branch", run)
with patch("agent_loop._tea_get", return_value={"workflow_runs": [run]}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNotNone(result)
def test_returns_none_when_no_runs(self):
with patch("agent_loop._tea_get", return_value={"workflow_runs": []}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertIsNone(result)
def test_returns_first_matching_run(self):
runs = [
self._make_pr_run("issue-166-fix", status="success"),
self._make_pr_run("issue-166-fix", status="failure"),
]
runs[0]["id"] = 10
runs[1]["id"] = 11
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
result = agent_loop._latest_ci_run_for_branch("issue-166-fix")
self.assertEqual(result["id"], 10)
class TestFindSessionUuid(unittest.TestCase):
"""Tests for _find_session_uuid()."""
def _write_jsonl(self, directory: Path, filename: str, entries: list) -> Path:
path = directory / filename
with path.open("w") as fh:
for entry in entries:
fh.write(json.dumps(entry) + "\n")
return path
def test_returns_uuid_for_matching_session_name(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-abc-123"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertEqual(result, "uuid-abc-123")
def test_returns_none_when_name_does_not_match(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "agent-name", "agentName": "issue-99", "sessionId": "uuid-abc-123"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_returns_none_when_directory_missing(self):
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = Path("/nonexistent/path/that/does/not/exist")
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_returns_none_when_no_agent_name_entry(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "abc123.jsonl", [
{"type": "message", "content": "hello"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertIsNone(result)
def test_scans_multiple_files_to_find_match(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects_dir = Path(tmpdir)
self._write_jsonl(projects_dir, "aaa.jsonl", [
{"type": "agent-name", "agentName": "issue-10", "sessionId": "uuid-10"},
])
self._write_jsonl(projects_dir, "bbb.jsonl", [
{"type": "agent-name", "agentName": "issue-91", "sessionId": "uuid-91"},
])
orig = agent_loop.CLAUDE_PROJECTS_DIR
agent_loop.CLAUDE_PROJECTS_DIR = projects_dir
try:
result = agent_loop._find_session_uuid("issue-91")
finally:
agent_loop.CLAUDE_PROJECTS_DIR = orig
self.assertEqual(result, "uuid-91")
class TestRunLoopResumeCommand(unittest.TestCase):
"""Tests that _run_loop() shows a UUID-based resume command when agent is running."""
def _alive_state(self, session_name="issue-91"):
return {
"pid": os.getpid(), # own PID is always alive
"issue": 91,
"started_at": "2026-05-23T12:00:00+00:00",
"type": "issue",
"session_name": session_name,
}
def test_resume_shows_uuid_when_found(self):
buf = io.StringIO()
fake_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
with patch("agent_loop._read_state", return_value=self._alive_state()), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=fake_uuid), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output)
def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=self._alive_state()), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=None), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("scripts/agent_loop.py list", output)
# Must NOT show the session name as a valid resume argument.
self.assertNotIn("claude --resume issue-91", output)
def test_resume_not_shown_when_no_session_name(self):
state = self._alive_state()
del state["session_name"]
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=state), \
patch("agent_loop._agent_alive", return_value=True), \
patch("agent_loop._agent_age_seconds", return_value=600), \
patch("agent_loop._find_session_uuid", return_value=None), \
patch("agent_loop._git_summary", return_value=""), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertNotIn("Resume:", output)
class TestCatchupSkipsQuestionIssues(unittest.TestCase):
"""Catch-up must not retry merging a PR whose issue is already State/Question."""
def _make_pr(self, pr_number=50, branch="issue-10-fix"):
return {"number": pr_number, "head": {"ref": branch}}
def test_skips_merge_when_issue_has_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_not_called()
mock_comment.assert_not_called()
mock_labels.assert_not_called()
def test_proceeds_with_merge_when_issue_lacks_question_label(self):
pr = self._make_pr()
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_IN_PROGRESS]), \
patch("agent_loop._merge_pr") as mock_merge, \
patch("agent_loop._find_pr_for_branch", return_value=None), \
patch("agent_loop._close_issue"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_merge.assert_called_once_with(50)
class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command."""
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
return {
"pid": 999999999,
"issue": issue,
"started_at": "2026-01-01T00:00:00+00:00",
"type": kind,
}
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
written_state = {}
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
written_state["pid"] = pid
written_state["issue"] = issue
written_state["kind"] = kind
written_state["session_name"] = session_name
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
patch("agent_loop._start_agent", return_value=77) as mock_start, \
patch("agent_loop._write_state", side_effect=fake_write_state), \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_start.assert_called_once()
prompt = mock_start.call_args[0][0]
self.assertIn("Rebase branch", prompt)
self.assertIn("issue-10-fix", prompt)
self.assertEqual(written_state.get("kind"), "pending-ci")
self.assertEqual(written_state.get("issue"), 10)
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
"""mergeable=true, second attempt succeeds → issue closed."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(), None]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(10)
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
"""All retries exhausted with PR still open → falls through to State/Question."""
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
patch("agent_loop._find_pr_for_branch",
side_effect=[self._open_pr(), self._open_pr(),
self._open_pr(), self._open_pr()]), \
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._merge_pr"), \
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
patch("agent_loop.time.sleep"), \
patch("agent_loop._set_labels") as mock_labels, \
patch("agent_loop._comment_issue") as mock_comment, \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._clear_state"):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
mock_labels.assert_called_once_with(
10,
add=[agent_loop.LABEL_QUESTION],
remove=[agent_loop.LABEL_IN_PROGRESS],
)
mock_comment.assert_called_once()
class TestHeartbeat(unittest.TestCase):
"""Tests for _update_heartbeat() and cmd_monitor()."""
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
self._tmp.close()
self._orig = agent_loop.HEARTBEAT_FILE
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no heartbeat file.
def tearDown(self):
agent_loop.HEARTBEAT_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_update_heartbeat_writes_timestamp(self):
agent_loop._update_heartbeat()
content = Path(self._tmp.name).read_text().strip()
dt = datetime.fromisoformat(content)
age = (datetime.now(timezone.utc) - dt).total_seconds()
self.assertLess(age, 5)
def test_update_heartbeat_creates_file(self):
self.assertFalse(Path(self._tmp.name).exists())
agent_loop._update_heartbeat()
self.assertTrue(Path(self._tmp.name).exists())
def test_monitor_healthy_when_recent(self):
agent_loop._update_heartbeat()
result = agent_loop.cmd_monitor()
self.assertEqual(result, 0)
def test_monitor_warns_when_heartbeat_missing(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_stale(self):
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
Path(self._tmp.name).write_text(stale)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_corrupted(self):
Path(self._tmp.name).write_text("not-a-timestamp")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_run_loop_updates_heartbeat(self):
self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop()
self.assertTrue(Path(self._tmp.name).exists())
if __name__ == "__main__":
unittest.main()
+33
View File
File diff suppressed because one or more lines are too long
+49 -49
View File
@@ -20,63 +20,67 @@ Future<imap.ImapClient> _fakeImapConnect(
throw const SocketException('fake — no real IMAP server in tests'); throw const SocketException('fake — no real IMAP server in tests');
void main() { void main() {
test('AccountSyncManager schedules IMAP sync for multiple accounts', test(
() async { 'AccountSyncManager schedules IMAP sync for multiple accounts',
final accounts = _FakeAccounts('pw'); () async {
final mailboxes = _FakeMailboxes(); final accounts = _FakeAccounts('pw');
final emails = _FakeEmails(); final mailboxes = _FakeMailboxes();
final logs = _FakeLogs(); final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager( final manager = AccountSyncManager(
accounts, accounts,
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
imapConnect: _fakeImapConnect, imapConnect: _fakeImapConnect,
); );
final a1 = _account('1'); final a1 = _account('1');
final a2 = _account('2'); final a2 = _account('2');
manager.start(); manager.start();
accounts.push([a1, a2]); accounts.push([a1, a2]);
// Allow some time for listeners to fire. // Allow some time for listeners to fire.
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose(); manager.dispose();
}); },
);
test('AccountSyncManager schedules JMAP sync for multiple accounts', test(
() async { 'AccountSyncManager schedules JMAP sync for multiple accounts',
final accounts = _FakeAccounts('pw'); () async {
final mailboxes = _FakeMailboxes(); final accounts = _FakeAccounts('pw');
final emails = _FakeEmails(); final mailboxes = _FakeMailboxes();
final logs = _FakeLogs(); final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager( final manager = AccountSyncManager(
accounts, accounts,
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
); );
final a1 = _jmapAccount('1'); final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2'); final a2 = _jmapAccount('2');
manager.start(); manager.start();
accounts.push([a1, a2]); accounts.push([a1, a2]);
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1)); expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose(); manager.dispose();
}); },
);
} }
Account _account(String id) => Account( Account _account(String id) => Account(
@@ -171,11 +175,7 @@ class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{}; final syncCounts = <String, int>{};
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
+51 -49
View File
@@ -566,59 +566,61 @@ void main() {
expect(pending.first.changeType, 'delete'); expect(pending.first.changeType, 'delete');
}); });
test('downloadAttachment fetches binary attachment bytes from IMAP', test(
() async { 'downloadAttachment fetches binary attachment bytes from IMAP',
final attachmentBytes = Uint8List.fromList( () async {
List.generate(32, (i) => i + 1), final attachmentBytes = Uint8List.fromList(
); List.generate(32, (i) => i + 1),
const attachmentName = 'hello.bin';
const attachmentMime = 'application/octet-stream';
// Build a multipart email with a binary attachment and append it.
final client = await _imapConnect(
host: imapHost,
port: imapPort,
user: userEmail,
pass: userPass,
);
try {
final builder = MessageBuilder()
..from = [MailAddress('Alice', userEmail)]
..to = [MailAddress('Alice', userEmail)]
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
..text = 'See attachment.';
builder.addBinary(
attachmentBytes,
MediaType.fromText(attachmentMime),
filename: attachmentName,
); );
await client.appendMessage( const attachmentName = 'hello.bin';
builder.buildMimeMessage(), const attachmentMime = 'application/octet-stream';
targetMailboxPath: 'INBOX',
// Build a multipart email with a binary attachment and append it.
final client = await _imapConnect(
host: imapHost,
port: imapPort,
user: userEmail,
pass: userPass,
); );
} finally { try {
await client.logout(); final builder = MessageBuilder()
} ..from = [MailAddress('Alice', userEmail)]
..to = [MailAddress('Alice', userEmail)]
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
..text = 'See attachment.';
builder.addBinary(
attachmentBytes,
MediaType.fromText(attachmentMime),
filename: attachmentName,
);
await client.appendMessage(
builder.buildMimeMessage(),
targetMailboxPath: 'INBOX',
);
} finally {
await client.logout();
}
final r = makeRepo(); final r = makeRepo();
await r.accounts.addAccount(account, userPass); await r.accounts.addAccount(account, userPass);
await r.emails.syncEmails('test', 'INBOX'); await r.emails.syncEmails('test', 'INBOX');
final emails = await r.emails.observeEmails('test', 'INBOX').first; final emails = await r.emails.observeEmails('test', 'INBOX').first;
expect(emails, hasLength(1)); expect(emails, hasLength(1));
expect(emails.first.hasAttachment, isTrue); expect(emails.first.hasAttachment, isTrue);
final body = await r.emails.getEmailBody(emails.first.id); final body = await r.emails.getEmailBody(emails.first.id);
expect(body.attachments, hasLength(1)); expect(body.attachments, hasLength(1));
expect(body.attachments.first.filename, attachmentName); expect(body.attachments.first.filename, attachmentName);
expect(body.attachments.first.contentType, attachmentMime); expect(body.attachments.first.contentType, attachmentMime);
expect(body.attachments.first.fetchPartId, isNotEmpty); expect(body.attachments.first.fetchPartId, isNotEmpty);
final path = await r.emails.downloadAttachment( final path = await r.emails.downloadAttachment(
emails.first.id, emails.first.id,
body.attachments.first, body.attachments.first,
); );
final downloaded = await File(path).readAsBytes(); final downloaded = await File(path).readAsBytes();
expect(downloaded, equals(attachmentBytes)); expect(downloaded, equals(attachmentBytes));
}); },
);
} }
@@ -73,13 +73,15 @@ abstract class AccountRepositoryContract {
expect(await repo.getPassword(_a.id), 'new'); expect(await repo.getPassword(_a.id), 'new');
}); });
test('removeAccount makes account disappear from observeAccounts', test(
() async { 'removeAccount makes account disappear from observeAccounts',
final repo = makeRepo(); () async {
await repo.addAccount(_a, 'pw'); final repo = makeRepo();
await repo.removeAccount(_a.id); await repo.addAccount(_a, 'pw');
expect(await repo.observeAccounts().first, isEmpty); await repo.removeAccount(_a.id);
}); expect(await repo.observeAccounts().first, isEmpty);
},
);
test('getAccount returns null after removeAccount', () async { test('getAccount returns null after removeAccount', () async {
final repo = makeRepo(); final repo = makeRepo();
+24 -27
View File
@@ -37,44 +37,41 @@ void main() {
// MissingPluginException (channel unavailable on the device), the IMAP sync // MissingPluginException (channel unavailable on the device), the IMAP sync
// loop must stop permanently instead of retrying indefinitely with backoff. // loop must stop permanently instead of retrying indefinitely with backoff.
test( test(
'MissingPluginException from secure storage stops IMAP sync loop permanently', 'MissingPluginException from secure storage stops IMAP sync loop permanently',
() async { () async {
final syncLog = FakeSyncLogRepository(); final syncLog = FakeSyncLogRepository();
final m = AccountSyncManager( final m = AccountSyncManager(
_AccountRepositoryWithMissingPlugin(), _AccountRepositoryWithMissingPlugin(),
FakeMailboxRepositoryWithInbox(), FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(), FakeEmailRepository(),
syncLog: syncLog, syncLog: syncLog,
); );
m.start(); m.start();
// Allow the first sync cycle to run and fail. // Allow the first sync cycle to run and fail.
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
expect(syncLog.logs, hasLength(1)); expect(syncLog.logs, hasLength(1));
expect(syncLog.logs.first.success, isFalse); expect(syncLog.logs.first.success, isFalse);
// Kicking the loop should have no effect once it has stopped permanently. // Kicking the loop should have no effect once it has stopped permanently.
m.syncNow('1'); m.syncNow('1');
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
// Before the fix: kick triggers a retry → 2 log entries. // Before the fix: kick triggers a retry → 2 log entries.
// After the fix: loop is permanently stopped → still exactly 1 entry. // After the fix: loop is permanently stopped → still exactly 1 entry.
expect(syncLog.logs, hasLength(1)); expect(syncLog.logs, hasLength(1));
m.dispose(); m.dispose();
}); },
);
} }
class FakeEmailRepository implements EmailRepository { class FakeEmailRepository implements EmailRepository {
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
+9 -8
View File
@@ -9,12 +9,13 @@ void main() {
// startup, throwing PlatformException(channel-error, ...). // startup, throwing PlatformException(channel-error, ...).
// registerBackgroundSync() must absorb the failure and let the app continue. // registerBackgroundSync() must absorb the failure and let the app continue.
test( test(
'registerBackgroundSync completes without throwing when plugin is unavailable', 'registerBackgroundSync completes without throwing when plugin is unavailable',
() async { () async {
// In the unit-test environment the native WorkManager plugin is not // In the unit-test environment the native WorkManager plugin is not
// registered, so Workmanager().initialize() throws a PlatformException or // registered, so Workmanager().initialize() throws a PlatformException or
// MissingPluginException. The fix catches it. This test fails before the // MissingPluginException. The fix catches it. This test fails before the
// fix (exception propagates) and passes after it (exception is swallowed). // fix (exception propagates) and passes after it (exception is swallowed).
await expectLater(registerBackgroundSync(), completes); await expectLater(registerBackgroundSync(), completes);
}); },
);
} }
+3 -2
View File
@@ -86,8 +86,9 @@ void main() {
final result = injectInlineImages(html, msg); final result = injectInlineImages(html, msg);
// Extract base64 payload from the data URI. // Extract base64 payload from the data URI.
final match = final match = RegExp(
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result); r'data:image/png;base64,([A-Za-z0-9+/=]+)',
).firstMatch(result);
expect(match, isNotNull); expect(match, isNotNull);
final decoded = base64.decode(match!.group(1)!); final decoded = base64.decode(match!.group(1)!);
expect(decoded.length, greaterThan(0)); expect(decoded.length, greaterThan(0));
+4 -17
View File
@@ -44,10 +44,7 @@ abstract class EmailRepositoryContract {
void run() { void run() {
test('observeEmails starts empty', () async { test('observeEmails starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty);
await repo.observeEmails(_account.id, 'INBOX').first,
isEmpty,
);
}); });
test('observeEmails emits inserted email', () async { test('observeEmails emits inserted email', () async {
@@ -61,10 +58,7 @@ abstract class EmailRepositoryContract {
test('observeEmails only returns emails for the given mailbox', () async { test('observeEmails only returns emails for the given mailbox', () async {
final repo = await makeRepo(); final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX'); await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
expect( expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty);
await repo.observeEmails(_account.id, 'Sent').first,
isEmpty,
);
}); });
test('observeEmails orders by receivedAt descending', () async { test('observeEmails orders by receivedAt descending', () async {
@@ -116,11 +110,7 @@ abstract class EmailRepositoryContract {
test('setFlag flagged updates isFlagged', () async { test('setFlag flagged updates isFlagged', () async {
final repo = await makeRepo(); final repo = await makeRepo();
await insertEmail( await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX');
repo,
id: 'er-acc:11',
mailboxPath: 'INBOX',
);
await repo.setFlag('er-acc:11', flagged: true); await repo.setFlag('er-acc:11', flagged: true);
final email = await repo.getEmail('er-acc:11'); final email = await repo.getEmail('er-acc:11');
expect(email!.isFlagged, isTrue); expect(email!.isFlagged, isTrue);
@@ -157,10 +147,7 @@ abstract class EmailRepositoryContract {
test('observeThreads starts empty', () async { test('observeThreads starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
}); });
} }
} }
+206 -199
View File
@@ -453,47 +453,49 @@ void main() {
expect(results.first.subject, 'foobar baz'); expect(results.first.subject, 'foobar baz');
}); });
test('searchAddresses returns results sorted by most recently used', test(
() async { 'searchAddresses returns results sorted by most recently used',
final r = _makeRepos(); () async {
await r.accounts.addAccount(_account, 'pw'); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
final older = DateTime(2024); final older = DateTime(2024);
final newer = DateTime(2024, 6); final newer = DateTime(2024, 6);
// Two emails — older one has alice@, newer one has bob@. // Two emails — older one has alice@, newer one has bob@.
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:old', id: 'acc-1:old',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 1, uid: 1,
receivedAt: older, receivedAt: older,
toAddresses: const Value( toAddresses: const Value(
'[{"name":"Alice","email":"alice@example.com"}]', '[{"name":"Alice","email":"alice@example.com"}]',
),
), ),
), );
); await r.db.into(r.db.emails).insert(
await r.db.into(r.db.emails).insert( EmailsCompanion.insert(
EmailsCompanion.insert( id: 'acc-1:new',
id: 'acc-1:new', accountId: 'acc-1',
accountId: 'acc-1', mailboxPath: 'Sent',
mailboxPath: 'Sent', uid: 2,
uid: 2, receivedAt: newer,
receivedAt: newer, toAddresses: const Value(
toAddresses: const Value( '[{"name":"Bob","email":"bob@example.com"}]',
'[{"name":"Bob","email":"bob@example.com"}]', ),
), ),
), );
);
// Query matching both; newer (bob) should come first. // Query matching both; newer (bob) should come first.
final results = await r.emails.searchAddresses(null, 'example'); final results = await r.emails.searchAddresses(null, 'example');
expect( expect(results.map((a) => a.email).toList(), [
results.map((a) => a.email).toList(), 'bob@example.com',
['bob@example.com', 'alice@example.com'], 'alice@example.com',
); ]);
}); },
);
// ── IMAP method tests ──────────────────────────────────────────────────── // ── IMAP method tests ────────────────────────────────────────────────────
@@ -697,47 +699,47 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test('snooze flush selects src mailbox and moves email to Snoozed', test(
() async { 'snooze flush selects src mailbox and moves email to Snoozed',
final spy = SnoozeSpyImapClient(); () async {
final r = _makeRepos( final spy = SnoozeSpyImapClient();
imapConnect: (_, __, ___) async => spy, final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
); await r.accounts.addAccount(_account, 'pw');
await r.accounts.addAccount(_account, 'pw'); await r.db.into(r.db.emails).insert(
await r.db.into(r.db.emails).insert( EmailsCompanion.insert(
EmailsCompanion.insert( id: 'acc-1:5',
id: 'acc-1:5', accountId: 'acc-1',
accountId: 'acc-1', mailboxPath: 'Snoozed',
mailboxPath: 'Snoozed', uid: 5,
uid: 5, receivedAt: DateTime(2024),
receivedAt: DateTime(2024), ),
), );
); await r.db.into(r.db.pendingChanges).insert(
await r.db.into(r.db.pendingChanges).insert( PendingChangesCompanion.insert(
PendingChangesCompanion.insert( accountId: 'acc-1',
accountId: 'acc-1', resourceType: 'Email',
resourceType: 'Email', resourceId: 'acc-1:5',
resourceId: 'acc-1:5', changeType: 'snooze',
changeType: 'snooze', payload: jsonEncode({
payload: jsonEncode({ 'uid': 5,
'uid': 5, 'src': 'INBOX',
'src': 'INBOX', 'dest': 'Snoozed',
'dest': 'Snoozed', 'until': '2026-05-10T15:00:00.000',
'until': '2026-05-10T15:00:00.000', }),
}), createdAt: DateTime.now(),
createdAt: DateTime.now(), ),
), );
);
await r.emails.flushPendingChanges('acc-1', 'pw'); await r.emails.flushPendingChanges('acc-1', 'pw');
// Change successfully applied — removed from queue. // Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Source mailbox extracted from 'src', not 'mailboxPath'. // Source mailbox extracted from 'src', not 'mailboxPath'.
expect(spy.selectedMailbox, 'INBOX'); expect(spy.selectedMailbox, 'INBOX');
expect(spy.createdMailbox, 'Snoozed'); expect(spy.createdMailbox, 'Snoozed');
expect(spy.movedToMailbox, 'Snoozed'); expect(spy.movedToMailbox, 'Snoozed');
}); },
);
}); });
group('Snooze', () { group('Snooze', () {
@@ -1640,119 +1642,123 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', test(
() async { 'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
final List<Map<String, dynamic>> capturedBodies = []; () async {
final client = MockClient((req) async { final List<Map<String, dynamic>> capturedBodies = [];
if (req.url.path.contains('well-known')) { final client = MockClient((req) async {
return http.Response( if (req.url.path.contains('well-known')) {
jsonEncode({ return http.Response(
'apiUrl': 'https://jmap.example.com/api/', jsonEncode({
'accounts': { 'apiUrl': 'https://jmap.example.com/api/',
'acct1': {'name': 'alice@example.com', 'isPersonal': true}, 'accounts': {
}, 'acct1': {'name': 'alice@example.com', 'isPersonal': true},
'primaryAccounts': { },
'urn:ietf:params:jmap:core': 'acct1', 'primaryAccounts': {
'urn:ietf:params:jmap:mail': 'acct1', 'urn:ietf:params:jmap:core': 'acct1',
}, 'urn:ietf:params:jmap:mail': 'acct1',
'capabilities': {}, },
'username': 'alice@example.com', 'capabilities': {},
'state': 'sess1', 'username': 'alice@example.com',
}), 'state': 'sess1',
200, }),
); 200,
} );
final body = jsonDecode(req.body) as Map<String, dynamic>; }
capturedBodies.add(body); final body = jsonDecode(req.body) as Map<String, dynamic>;
final calls = body['methodCalls'] as List; capturedBodies.add(body);
final methodName = (calls.first as List)[0] as String; final calls = body['methodCalls'] as List;
if (methodName == 'Mailbox/set') { final methodName = (calls.first as List)[0] as String;
if (methodName == 'Mailbox/set') {
return http.Response(
jsonEncode({
'sessionState': 's1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0',
],
],
}),
200,
);
}
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
'sessionState': 's1', 'sessionState': 's1',
'methodResponses': [ 'methodResponses': [
[ [
'Mailbox/set', 'Email/set',
{ {'accountId': 'acct1', 'updated': {}},
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0', '0',
], ],
], ],
}), }),
200, 200,
); );
} });
return http.Response(
jsonEncode({ final r = _makeRepos(httpClient: client);
'sessionState': 's1', await seedChange(
'methodResponses': [ r.db,
[ r.accounts,
'Email/set', changeType: 'snooze',
{'accountId': 'acct1', 'updated': {}}, payload: jsonEncode({
'0', 'uid': 0,
], 'src': 'mbx-inbox',
], 'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}), }),
200,
); );
});
final r = _makeRepos(httpClient: client); await r.emails.flushPendingChanges('jmap-1', 'pw');
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
await r.emails.flushPendingChanges('jmap-1', 'pw'); // Change successfully applied — removed from queue.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Change successfully applied — removed from queue. // First API call should be Mailbox/set to create the Snoozed folder.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(capturedBodies, hasLength(2));
final firstCall =
((capturedBodies.first['methodCalls'] as List).first as List)[0];
expect(firstCall, 'Mailbox/set');
// First API call should be Mailbox/set to create the Snoozed folder. // Second call should be Email/set using the newly created mailbox ID.
expect(capturedBodies, hasLength(2)); final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
final firstCall = as List)[1] as Map<String, dynamic>;
((capturedBodies.first['methodCalls'] as List).first as List)[0]; final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
expect(firstCall, 'Mailbox/set'); as Map<String, dynamic>;
expect(update['mailboxIds/mbx-snoozed'], true);
},
);
// Second call should be Email/set using the newly created mailbox ID. test(
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first 'snooze uses existing mailbox ID when dest is already a JMAP ID',
as List)[1] as Map<String, dynamic>; () async {
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1'] final r = _makeRepos(httpClient: mockFlush(200));
as Map<String, dynamic>; await seedChange(
expect(update['mailboxIds/mbx-snoozed'], true); r.db,
}); r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'mbx-snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
test('snooze uses existing mailbox ID when dest is already a JMAP ID', await r.emails.flushPendingChanges('jmap-1', 'pw');
() async {
final r = _makeRepos(httpClient: mockFlush(200));
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'mbx-snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
await r.emails.flushPendingChanges('jmap-1', 'pw'); // Change applied without needing Mailbox/set (dest was already a valid ID).
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Change applied without needing Mailbox/set (dest was already a valid ID). },
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); );
});
}); });
group('JMAP syncEmails body caching', () { group('JMAP syncEmails body caching', () {
@@ -2282,41 +2288,42 @@ void main() {
group('concurrent moves', () { group('concurrent moves', () {
test( test(
'two simultaneous moves enqueue two changes and leave email in last destination', 'two simultaneous moves enqueue two changes and leave email in last destination',
() async { () async {
final r = _makeRepos(); final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw'); await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert( await r.db.into(r.db.emails).insert(
EmailsCompanion.insert( EmailsCompanion.insert(
id: 'acc-1:5', id: 'acc-1:5',
accountId: 'acc-1', accountId: 'acc-1',
mailboxPath: 'INBOX', mailboxPath: 'INBOX',
uid: 5, uid: 5,
receivedAt: DateTime(2024), receivedAt: DateTime(2024),
), ),
); );
// Fire both moves without awaiting to exercise concurrent enqueue logic. // Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive'); final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash'); final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]); await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get(); final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2)); expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move')); expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations = final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet(); changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash'])); expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5'); final email = await r.emails.getEmail('acc-1:5');
expect( expect(
email!.mailboxPath, email!.mailboxPath,
anyOf('Archive', 'Trash'), anyOf('Archive', 'Trash'),
reason: reason:
'email must be optimistically moved to one of the two destinations', 'email must be optimistically moved to one of the two destinations',
); );
}); },
);
}); });
group('IMAP SMTP auth failure', () { group('IMAP SMTP auth failure', () {
@@ -61,10 +61,7 @@ abstract class MailboxRepositoryContract {
test('findMailboxByRole returns null when no match', () async { test('findMailboxByRole returns null when no match', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect( expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull);
await repo.findMailboxByRole(_account.id, 'archive'),
isNull,
);
}); });
test('findMailboxByRole returns the matching mailbox', () async { test('findMailboxByRole returns the matching mailbox', () async {
+69 -67
View File
@@ -486,8 +486,11 @@ void main() {
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes final result = await r.mailboxes.createMailboxWithRole(
.createMailboxWithRole('jmap-1', 'Archive', 'archive'); 'jmap-1',
'Archive',
'archive',
);
expect(result.name, 'Archive'); expect(result.name, 'Archive');
expect(result.role, 'archive'); expect(result.role, 'archive');
@@ -498,81 +501,80 @@ void main() {
expect(found!.name, 'Archive'); expect(found!.name, 'Archive');
}); });
test( test('JMAP: throws when server returns no created ID', () async {
'JMAP: throws when server returns no created ID', final r = _makeRepos(
() async { httpClient: _mockJmap(
final r = _makeRepos( apiResponses: [
httpClient: _mockJmap( {
apiResponses: [ 'sessionState': 'sess1',
{ 'methodResponses': [
'sessionState': 'sess1', [
'methodResponses': [ 'Mailbox/set',
[ {
'Mailbox/set', 'accountId': 'acct1',
{ 'created': null,
'accountId': 'acct1', 'notCreated': {
'created': null, 'new-mailbox': {'type': 'serverFail'},
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
}, },
'0', },
], '0',
], ],
}, ],
], },
), ],
); ),
await r.accounts.addAccount(_jmapAccount, 'pw'); );
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater( await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'), r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()), throwsA(isA<Exception>()),
); );
}, });
);
}); });
group('syncMailboxes IMAP preserves manually-set role', () { group('syncMailboxes IMAP preserves manually-set role', () {
test('existing role is kept when server returns no special-use flag', test(
() async { 'existing role is kept when server returns no special-use flag',
final spy = SnoozeSpyImapClient(); () async {
// Make listMailboxes return a plain folder without \Archive. final spy = SnoozeSpyImapClient();
final db = openTestDatabase(); // Make listMailboxes return a plain folder without \Archive.
final accounts = AccountRepositoryImpl(db, MapSecureStorage()); final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder. // Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient(); final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl( final mailboxes = MailboxRepositoryImpl(
db, db,
accounts, accounts,
imapConnect: (_, __, ___) async => fakeClient, imapConnect: (_, __, ___) async => fakeClient,
); );
await accounts.addAccount(_account, 'pw'); await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder). // Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert( await db.into(db.mailboxes).insert(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: 'acc-1:Archive', id: 'acc-1:Archive',
accountId: 'acc-1', accountId: 'acc-1',
path: 'Archive', path: 'Archive',
name: 'Archive', name: 'Archive',
role: const Value('archive'), role: const Value('archive'),
), ),
); );
await mailboxes.syncMailboxes('acc-1'); await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive'); final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect( expect(
found, found,
isNotNull, isNotNull,
reason: 'Manually-set role should be preserved after sync', reason: 'Manually-set role should be preserved after sync',
); );
expect(found!.path, 'Archive'); expect(found!.path, 'Archive');
// Suppress unused warning on spy. // Suppress unused warning on spy.
expect(spy, isNotNull); expect(spy, isNotNull);
}); },
);
}); });
}); });
} }
+100 -75
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 34); expect(db.schemaVersion, 36);
await db.close(); await db.close();
}); });
@@ -178,17 +178,17 @@ void main() {
// v28: mime_tree_json column on email_bodies. // v28: mime_tree_json column on email_bodies.
await db await db
.customSelect( .customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.get(); .get();
// v29: local_sieve_scripts table. // v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = final syncLogMailboxColumns = await _tableColumns(
await _tableColumns(db, 'sync_log_mailboxes'); db,
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
@@ -202,19 +202,26 @@ void main() {
// v34: user_preferences table. // v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get(); await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test( test(
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id', 'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async { () async {
final dbFile = File('test_migration_v22.db'); final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL. // Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path); final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute(''' rawDb.execute('''
CREATE TABLE accounts ( CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
@@ -235,7 +242,7 @@ void main() {
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1)) verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE drafts ( CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL, account_id TEXT NULL,
@@ -247,7 +254,7 @@ void main() {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE mailboxes ( CREATE TABLE mailboxes (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -258,7 +265,7 @@ void main() {
role TEXT NULL role TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE emails ( CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -282,7 +289,7 @@ void main() {
snoozed_from_mailbox_path TEXT NULL snoozed_from_mailbox_path TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE threads ( CREATE TABLE threads (
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL, mailbox_path TEXT NOT NULL,
@@ -299,7 +306,7 @@ void main() {
PRIMARY KEY (account_id, mailbox_path, id) PRIMARY KEY (account_id, mailbox_path, id)
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE email_bodies ( CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE, email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
text_body TEXT NULL, text_body TEXT NULL,
@@ -309,7 +316,7 @@ void main() {
headers_json TEXT NULL headers_json TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE sync_logs ( CREATE TABLE sync_logs (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
@@ -326,7 +333,7 @@ void main() {
protocol_log TEXT NULL protocol_log TEXT NULL
); );
'''); ''');
rawDb.execute(''' rawDb.execute('''
CREATE TABLE sync_log_mailboxes ( CREATE TABLE sync_log_mailboxes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE, sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
@@ -336,72 +343,81 @@ void main() {
bytes_transferred INTEGER NOT NULL DEFAULT 0 bytes_transferred INTEGER NOT NULL DEFAULT 0
); );
'''); ''');
rawDb.execute('PRAGMA user_version = 22;'); rawDb.execute('PRAGMA user_version = 22;');
rawDb.close(); rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile)); final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration. // Trigger migration.
await db.select(db.accounts).get(); await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails'); final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header')); expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts'); final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id')); expect(draftColumns, contains('imap_server_id'));
// v25: new indexes on mailboxes and threads. // v25: new indexes on mailboxes and threads.
final allIndexes = await db final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'") .customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get(); .get();
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet(); final indexNames =
expect(indexNames, contains('mailboxes_account_id')); allIndexes.map((r) => r.read<String>('name')).toSet();
expect(indexNames, contains('threads_latest_date')); expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('threads_latest_date'));
// v26: FTS5 virtual table and triggers. // v26: FTS5 virtual table and triggers.
final allTriggers = await db final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'") .customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get(); .get();
final triggerNames = final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet(); allTriggers.map((r) => r.read<String>('name')).toSet();
expect( expect(
triggerNames, triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']), containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
); );
await db.customSelect('SELECT count(*) FROM email_fts').get(); await db.customSelect('SELECT count(*) FROM email_fts').get();
// v27: search_history_entries table. // v27: search_history_entries table.
await db await db
.customSelect('SELECT count(*) FROM search_history_entries') .customSelect('SELECT count(*) FROM search_history_entries')
.get(); .get();
// v28: mime_tree_json column on email_bodies. // v28: mime_tree_json column on email_bodies.
await db await db
.customSelect( .customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
'SELECT mime_tree_json FROM email_bodies LIMIT 0', .get();
)
.get();
// v29: local_sieve_scripts table. // v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = final syncLogMailboxColumns = await _tableColumns(
await _tableColumns(db, 'sync_log_mailboxes'); db,
expect(syncLogMailboxColumns, contains('duration_ms')); 'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs. // v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs'); final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace')); expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent')); expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table. // v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get(); await db.customSelect('SELECT count(*) FROM user_preferences').get();
await db.close(); // v35: mail_view_button_position column on user_preferences.
if (dbFile.existsSync()) dbFile.deleteSync(); final userPrefsColumns = await _tableColumns(db, 'user_preferences');
}); expect(userPrefsColumns, contains('mail_view_button_position'));
test('fresh install creates all tables at schemaVersion 34', () async { // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -439,8 +455,10 @@ void main() {
expect(draftColumns, contains('imap_server_id')); expect(draftColumns, contains('imap_server_id'));
// v30: duration_ms column on sync_log_mailboxes. // v30: duration_ms column on sync_log_mailboxes.
final syncLogMailboxColumns = final syncLogMailboxColumns = await _tableColumns(
await _tableColumns(db, 'sync_log_mailboxes'); db,
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs. // v33: error_stack_trace and is_permanent columns on sync_logs.
@@ -448,6 +466,13 @@ void main() {
expect(syncLogColumns, contains('error_stack_trace')); expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent')); expect(syncLogColumns, contains('is_permanent'));
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
}); });
}); });
+9 -8
View File
@@ -9,14 +9,15 @@ void main() {
// absent at startup, throwing MissingPluginException (or a similar error). // absent at startup, throwing MissingPluginException (or a similar error).
// initNotifications() must absorb the failure and let the app continue. // initNotifications() must absorb the failure and let the app continue.
test( test(
'initNotifications completes without throwing when plugin is unavailable', 'initNotifications completes without throwing when plugin is unavailable',
() async { () async {
// In the unit-test environment the native plugin is not registered, so // In the unit-test environment the native plugin is not registered, so
// _plugin.initialize() throws. The fix catches it and keeps _initialized // _plugin.initialize() throws. The fix catches it and keeps _initialized
// false. This test fails before the fix (exception propagates) and passes // false. This test fails before the fix (exception propagates) and passes
// after it (exception is swallowed). // after it (exception is swallowed).
await expectLater(initNotifications(), completes); await expectLater(initNotifications(), completes);
}); },
);
test('showNewMailNotification completes without throwing', () async { test('showNewMailNotification completes without throwing', () async {
// Platform.isAndroid is false in tests, so this returns early without // Platform.isAndroid is false in tests, so this returns early without
+4 -10
View File
@@ -26,11 +26,9 @@ class _FakeAccounts implements AccountRepository {
@override @override
Stream<List<Account>> observeAccounts() => Stream.value(accounts); Stream<List<Account>> observeAccounts() => Stream.value(accounts);
@override @override
Future<Account?> getAccount(String id) async => Future<Account?> getAccount(String id) async => accounts
accounts.cast<Account?>().firstWhere( .cast<Account?>()
(a) => a?.id == id, .firstWhere((a) => a?.id == id, orElse: () => null);
orElse: () => null,
);
@override @override
Future<void> addAccount(Account account, String password) async {} Future<void> addAccount(Account account, String password) async {}
@override @override
@@ -94,11 +92,7 @@ class _CountingEmails implements EmailRepository {
@override @override
Future<int> flushPendingChanges(String accountId, String password) async => 0; Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override @override
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
+1 -3
View File
@@ -47,9 +47,7 @@ void main() {
test('parsePublicKeyQr returns null for invalid input', () { test('parsePublicKeyQr returns null for invalid input', () {
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull); expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
expect( expect(
ShareEncryptionService.parsePublicKeyQr( ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
'sharedinbox.de:pubkey:v1:!!!',
),
isNull, isNull,
); );
expect( expect(
+5 -7
View File
@@ -73,11 +73,7 @@ void main() {
SieveRule( SieveRule(
joinType: 'single', joinType: 'single',
conditions: [ conditions: [
HeaderCondition( HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
['from', 'reply-to'],
':is',
['boss@work.com'],
),
], ],
actions: [ actions: [
FlagAction([r'\Important']), FlagAction([r'\Important']),
@@ -121,8 +117,10 @@ void main() {
), ),
]; ];
final ctx = final ctx = interp.execute(
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue')); rules,
_email(subject: 'Weekly Newsletter Issue'),
);
expect(ctx.targetFolders, contains('Bulk')); expect(ctx.targetFolders, contains('Bulk'));
}); });
}); });
+3 -2
View File
@@ -261,8 +261,9 @@ if exists "X-Spam-Flag" {
group('SieveParser — rule model', () { group('SieveParser — rule model', () {
test('simple if produces one rule with branchGroupId', () { test('simple if produces one rule with branchGroupId', () {
final rules = final rules = parser.parse(
parser.parse('if header :contains "Subject" "x" { discard; }'); 'if header :contains "Subject" "x" { discard; }',
);
expect(rules, hasLength(1)); expect(rules, hasLength(1));
expect(rules.first.branchGroupId, isNotNull); expect(rules.first.branchGroupId, isNotNull);
expect(rules.first.conditions, hasLength(1)); expect(rules.first.conditions, hasLength(1));
+29 -27
View File
@@ -127,33 +127,35 @@ void main() {
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
}); });
test('stores and retrieves stackTrace and isPermanent on error entries', test(
() async { 'stores and retrieves stackTrace and isPermanent on error entries',
final repo = SyncLogRepositoryImpl(db); () async {
final start = DateTime(2024, 3, 1, 9); final repo = SyncLogRepositoryImpl(db);
final end = DateTime(2024, 3, 1, 9, 0, 1); final start = DateTime(2024, 3, 1, 9);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)'; final end = DateTime(2024, 3, 1, 9, 0, 1);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
await repo.log( await repo.log(
accountId: 'acc1', accountId: 'acc1',
success: false, success: false,
errorMessage: 'MissingPluginException', errorMessage: 'MissingPluginException',
stackTrace: fakeTrace, stackTrace: fakeTrace,
isPermanent: true, isPermanent: true,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
mailboxesSynced: 0, mailboxesSynced: 0,
pendingFlushed: 0, pendingFlushed: 0,
bytesTransferred: 0, bytesTransferred: 0,
startedAt: start, startedAt: start,
finishedAt: end, finishedAt: end,
); );
final entries = await repo.observeSyncLogs('acc1').first; final entries = await repo.observeSyncLogs('acc1').first;
final entry = entries.firstWhere((e) => e.startedAt == start); final entry = entries.firstWhere((e) => e.startedAt == start);
expect(entry.stackTrace, fakeTrace); expect(entry.stackTrace, fakeTrace);
expect(entry.isPermanent, true); expect(entry.isPermanent, true);
expect(entry.errorMessage, 'MissingPluginException'); expect(entry.errorMessage, 'MissingPluginException');
}); },
);
} }
+6 -4
View File
@@ -260,8 +260,9 @@ void main() {
expect(original!.messageId, isNull); // set a messageId so lookup works expect(original!.messageId, isNull); // set a messageId so lookup works
// Seed a messageId so undo can find the email after UID change. // Seed a messageId so undo can find the email after UID change.
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))) await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write(
.write(const EmailsCompanion(messageId: Value('msg-101@test'))); const EmailsCompanion(messageId: Value('msg-101@test')),
);
final originalWithMsgId = await repo.getEmail(oldEmailId); final originalWithMsgId = await repo.getEmail(oldEmailId);
@@ -303,8 +304,9 @@ void main() {
await container.read(undoServiceProvider.notifier).undo(); await container.read(undoServiceProvider.notifier).undo();
// 4. Verify the current email row is now in INBOX. // 4. Verify the current email row is now in INBOX.
final inInbox = await (db.select(db.emails) final inInbox = await (db.select(
..where((t) => t.mailboxPath.equals('INBOX'))) db.emails,
)..where((t) => t.mailboxPath.equals('INBOX')))
.get(); .get();
expect( expect(
inInbox, inInbox,
+64 -64
View File
@@ -122,70 +122,74 @@ void main() {
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
}); });
test('undo pushes inverse action into log when destinationMailboxPath is set', test(
() async { 'undo pushes inverse action into log when destinationMailboxPath is set',
final action = UndoAction( () async {
id: 'del1', final action = UndoAction(
accountId: 'acc1', id: 'del1',
type: UndoType.delete, accountId: 'acc1',
emailIds: ['e1'], type: UndoType.delete,
sourceMailboxPath: 'INBOX', emailIds: ['e1'],
destinationMailboxPath: 'Trash', sourceMailboxPath: 'INBOX',
); destinationMailboxPath: 'Trash',
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when( when(
mockEmailRepo.cancelPendingChange(any, any), mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
await notifier.pushAction(action); await notifier.pushAction(action);
await notifier.undo(actionId: 'del1'); await notifier.undo(actionId: 'del1');
// Original entry stays; inverse is added. // Original entry stays; inverse is added.
final log = container.read(undoServiceProvider); final log = container.read(undoServiceProvider);
expect(log.length, 2); expect(log.length, 2);
expect(log[0].id, 'del1'); expect(log[0].id, 'del1');
final inv = log[1]; final inv = log[1];
expect(inv.id, 'del1-inv'); expect(inv.id, 'del1-inv');
expect(inv.type, UndoType.move); expect(inv.type, UndoType.move);
expect(inv.emailIds, ['e1']); expect(inv.emailIds, ['e1']);
expect(inv.sourceMailboxPath, 'Trash'); expect(inv.sourceMailboxPath, 'Trash');
expect(inv.destinationMailboxPath, 'INBOX'); expect(inv.destinationMailboxPath, 'INBOX');
verify( verify(
mockUndoRepo.saveAction( mockUndoRepo.saveAction(
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')), argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
), ),
).called(1); ).called(1);
}); },
);
test('undo without destinationMailboxPath does not push inverse action', test(
() async { 'undo without destinationMailboxPath does not push inverse action',
final action = UndoAction( () async {
id: 'mv1', final action = UndoAction(
accountId: 'acc1', id: 'mv1',
type: UndoType.move, accountId: 'acc1',
emailIds: ['e1'], type: UndoType.move,
sourceMailboxPath: 'INBOX', emailIds: ['e1'],
// no destinationMailboxPath sourceMailboxPath: 'INBOX',
); // no destinationMailboxPath
);
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
when( when(
mockEmailRepo.cancelPendingChange(any, any), mockEmailRepo.cancelPendingChange(any, any),
).thenAnswer((_) async => false); ).thenAnswer((_) async => false);
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); await notifier.init();
await notifier.pushAction(action); await notifier.pushAction(action);
await notifier.undo(actionId: 'mv1'); await notifier.undo(actionId: 'mv1');
// Original entry stays; no inverse since no destinationMailboxPath. // Original entry stays; no inverse since no destinationMailboxPath.
final log = container.read(undoServiceProvider); final log = container.read(undoServiceProvider);
expect(log.length, 1); expect(log.length, 1);
expect(log.first.id, 'mv1'); expect(log.first.id, 'mv1');
}); },
);
test('undo with actionId removes and undos specific action', () async { test('undo with actionId removes and undos specific action', () async {
// action1 has no destination → no inverse action // action1 has no destination → no inverse action
@@ -350,13 +354,9 @@ void main() {
); );
// Simulate slow DB load // Simulate slow DB load
when( when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer(
mockUndoRepo.getHistory(limit: anyNamed('limit')), (_) =>
).thenAnswer( Future.delayed(const Duration(milliseconds: 10), () => [persisted]),
(_) => Future.delayed(
const Duration(milliseconds: 10),
() => [persisted],
),
); );
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
+8 -8
View File
@@ -46,8 +46,9 @@ class ThrowingUrlLauncher extends Mock
Widget _buildScreen({List<Account> accounts = const []}) { Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
accountRepositoryProvider accountRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeAccountRepository(accounts)), FakeAccountRepository(accounts),
),
], ],
child: const MaterialApp(home: AboutScreen()), child: const MaterialApp(home: AboutScreen()),
); );
@@ -151,8 +152,10 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler(SystemChannels.platform, null), SystemChannels.platform,
null,
),
); );
await tester.pumpWidget(_buildScreen()); await tester.pumpWidget(_buildScreen());
@@ -173,10 +176,7 @@ void main() {
expect(clipboardText, contains('Locale')); expect(clipboardText, contains('Locale'));
expect(clipboardText, contains('Text Scale')); expect(clipboardText, contains('Text Scale'));
expect(clipboardText, contains('DB Schema Version')); expect(clipboardText, contains('DB Schema Version'));
expect( expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)'));
clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'),
);
}); });
testWidgets('AboutScreen create-issue button opens Codeberg URL', ( testWidgets('AboutScreen create-issue button opens Codeberg URL', (
+2 -8
View File
@@ -74,10 +74,7 @@ void main() {
recipientKeyId: material.keyId, recipientKeyId: material.keyId,
recipientPublicKeyBytes: material.publicKeyBytes, recipientPublicKeyBytes: material.publicKeyBytes,
accounts: [ accounts: [
AccountPayload( AccountPayload(accountJson: account.toJson(), password: 'secret'),
accountJson: account.toJson(),
password: 'secret',
),
], ],
); );
@@ -99,10 +96,7 @@ void main() {
await tester.tap(find.text('Import')); await tester.tap(find.text('Import'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.text('Imported 1 account successfully.'), findsOneWidget);
find.text('Imported 1 account successfully.'),
findsOneWidget,
);
}, },
); );
+45 -22
View File
@@ -227,30 +227,53 @@ void main() {
expect(find.textContaining('Healthy'), findsOneWidget); expect(find.textContaining('Healthy'), findsOneWidget);
}); });
testWidgets( testWidgets('shows discrepancy details when sync health has discrepancies',
'shows discrepancy details when sync health has discrepancies', (
(tester) async { tester,
const summary = ) async {
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; const summary =
await tester.pumpWidget( '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
buildApp( await tester.pumpWidget(
initialLocation: '/accounts', buildApp(
overrides: baseOverrides( initialLocation: '/accounts',
accounts: [kTestAccount], overrides: baseOverrides(
syncHealth: SyncHealthRow( accounts: [kTestAccount],
accountId: kTestAccount.id, syncHealth: SyncHealthRow(
lastVerifiedAt: DateTime(2024, 6), accountId: kTestAccount.id,
isHealthy: false, lastVerifiedAt: DateTime(2024, 6),
discrepancySummary: summary, isHealthy: false,
), discrepancySummary: summary,
), ),
), ),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
expect(find.textContaining('missing locally: 3'), findsOneWidget); expect(find.textContaining('missing locally: 3'), findsOneWidget);
expect(find.textContaining('flag mismatches: 1'), findsOneWidget); expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
}, });
);
testWidgets('sync health row is positioned below the account name row', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos));
});
}); });
} }
+68 -66
View File
@@ -96,8 +96,10 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler(SystemChannels.platform, null), SystemChannels.platform,
null,
),
); );
const exception = 'TestException: clipboard test'; const exception = 'TestException: clipboard test';
@@ -126,79 +128,77 @@ void main() {
}, },
); );
testWidgets( testWidgets('CrashScreen shows git hash as clickable link above stacktrace', (
'CrashScreen shows git hash as clickable link above stacktrace', tester,
(tester) async { ) async {
tester.view.physicalSize = const Size(800, 1200); tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize()); addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher(); final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock; UrlLauncherPlatform.instance = mock;
const exception = 'TestException: git hash test'; const exception = 'TestException: git hash test';
final stackTrace = StackTrace.current; final stackTrace = StackTrace.current;
const testHash = 'abc1234'; const testHash = 'abc1234';
await tester.pumpWidget( await tester.pumpWidget(
CrashScreen( CrashScreen(
exception: exception, exception: exception,
stackTrace: stackTrace, stackTrace: stackTrace,
gitHash: testHash, gitHash: testHash,
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Git hash link should be present // Git hash link should be present
final gitLinkFinder = find.textContaining('Git Commit: abc1234'); final gitLinkFinder = find.textContaining('Git Commit: abc1234');
expect(gitLinkFinder, findsOneWidget); expect(gitLinkFinder, findsOneWidget);
// Link must appear above the stack trace // Link must appear above the stack trace
final stackTraceFinder = find.text('Stack Trace:'); final stackTraceFinder = find.text('Stack Trace:');
expect( expect(
tester.getTopLeft(gitLinkFinder).dy, tester.getTopLeft(gitLinkFinder).dy,
lessThan(tester.getTopLeft(stackTraceFinder).dy), lessThan(tester.getTopLeft(stackTraceFinder).dy),
); );
// Tapping the link should open the Codeberg commit URL // Tapping the link should open the Codeberg commit URL
await tester.tap(gitLinkFinder); await tester.tap(gitLinkFinder);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
mock.launchedUrl, mock.launchedUrl,
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'), equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
); );
}, });
);
testWidgets( testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
'CrashScreen shows version, build mode, and platform in the UI', tester,
(tester) async { ) async {
tester.view.physicalSize = const Size(800, 1200); tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize()); addTearDown(() => tester.view.resetPhysicalSize());
const exception = 'TestException: info row test'; const exception = 'TestException: info row test';
final stackTrace = StackTrace.current; final stackTrace = StackTrace.current;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: CrashScreen(exception: exception, stackTrace: stackTrace), home: CrashScreen(exception: exception, stackTrace: stackTrace),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Info row shows app version (from mock), build mode, and platform OS. // Info row shows app version (from mock), build mode, and platform OS.
expect(find.textContaining('1.0.0+42'), findsWidgets); expect(find.textContaining('1.0.0+42'), findsWidgets);
// In test builds kDebugMode is true. // In test builds kDebugMode is true.
expect(find.textContaining('debug'), findsOneWidget); expect(find.textContaining('debug'), findsOneWidget);
// Platform OS is always present (linux in CI, android/ios on device). // Platform OS is always present (linux in CI, android/ios on device).
expect( expect(
find.textContaining(RegExp(r'linux|android|ios|windows|macos')), find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
findsWidgets, findsWidgets,
); );
}, });
);
testWidgets( testWidgets(
'CrashScreen shows app version as clickable link when git hash is set', 'CrashScreen shows app version as clickable link when git hash is set',
@@ -264,8 +264,10 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler(SystemChannels.platform, null), SystemChannels.platform,
null,
),
); );
const exception = 'TestException: version link clipboard test'; const exception = 'TestException: version link clipboard test';
+50 -49
View File
@@ -106,62 +106,62 @@ void main() {
}); });
testWidgets( testWidgets(
'try connection button is disabled when no password stored or entered', 'try connection button is disabled when no password stored or entered',
( (tester) async {
tester, tester.view.physicalSize = const Size(800, 1400);
) async { tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(800, 1400); addTearDown(tester.view.resetPhysicalSize);
tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/edit', initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides( overrides: baseOverrides(
accounts: [kTestAccount], accounts: [kTestAccount],
hasStoredPassword: false, hasStoredPassword: false,
),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
final button = tester.widget<OutlinedButton>( final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')), find.byKey(const Key('editTryConnectionButton')),
); );
expect(button.onPressed, isNull); expect(button.onPressed, isNull);
}); },
);
testWidgets( testWidgets(
'try connection button is enabled after typing password with no stored password', 'try connection button is enabled after typing password with no stored password',
(tester) async { (tester) async {
tester.view.physicalSize = const Size(800, 1400); tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/edit', initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides( overrides: baseOverrides(
accounts: [kTestAccount], accounts: [kTestAccount],
hasStoredPassword: false, hasStoredPassword: false,
),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
await tester.enterText( await tester.enterText(
find.byKey(const Key('editPasswordField')), find.byKey(const Key('editPasswordField')),
'mypassword', 'mypassword',
); );
await tester.pump(); await tester.pump();
final button = tester.widget<OutlinedButton>( final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')), find.byKey(const Key('editTryConnectionButton')),
); );
expect(button.onPressed, isNotNull); expect(button.onPressed, isNotNull);
}); },
);
testWidgets('save button is disabled when no password stored or entered', ( testWidgets('save button is disabled when no password stored or entered', (
tester, tester,
@@ -182,8 +182,9 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final button = tester final button = tester.widget<FilledButton>(
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save')); find.widgetWithText(FilledButton, 'Save'),
);
expect(button.onPressed, isNull); expect(button.onPressed, isNull);
}); });
+120 -123
View File
@@ -52,10 +52,7 @@ List<Override> _overrides({required EmailBody body, Email? email}) => [
), ),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
emailDetail: email ?? testEmail(),
emailBody: body,
),
), ),
]; ];
@@ -191,45 +188,45 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'),
(w) => w is Tooltip && w.message == 'Reply all',
),
findsNothing, findsNothing,
); );
}); });
testWidgets('Reply on single-recipient email navigates directly to compose', testWidgets(
(tester) async { 'Reply on single-recipient email navigates directly to compose',
// testEmail has from=[bob], to=[alice]. After removing alice (own), (tester) async {
// only bob remains → no dialog, navigate straight to compose. // testEmail has from=[bob], to=[alice]. After removing alice (own),
final email = testEmail(); // only bob remains → no dialog, navigate straight to compose.
await tester.pumpWidget( final email = testEmail();
buildApp( await tester.pumpWidget(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', buildApp(
overrides: [ initialLocation:
..._overrides( '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
body: const EmailBody(emailId: 'acc-1:42', attachments: []), overrides: [
email: email, ..._overrides(
), body: const EmailBody(emailId: 'acc-1:42', attachments: []),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), email: email,
], ),
), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
); ],
await tester.pumpAndSettle(); ),
);
await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
(w) => w is Tooltip && w.message == 'Reply', );
), await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
// No dialog shown — straight navigation to compose. // No dialog shown — straight navigation to compose.
expect(find.text('Reply All'), findsNothing); expect(find.text('Reply All'), findsNothing);
}); },
);
testWidgets('Reply on multi-recipient email shows Reply All dialog', testWidgets('Reply on multi-recipient email shows Reply All dialog', (
(tester) async { tester,
) async {
// Email with an extra Cc recipient so the dialog is triggered. // Email with an extra Cc recipient so the dialog is triggered.
final email = Email( final email = Email(
id: 'acc-1:42', id: 'acc-1:42',
@@ -258,9 +255,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
(w) => w is Tooltip && w.message == 'Reply',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -271,7 +266,9 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1)); expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
}); });
testWidgets('Mark as spam button is present in app bar', (tester) async { testWidgets('Mark as spam is in popup menu, not a standalone button', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -282,16 +279,24 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam', (w) => w is Tooltip && w.message == 'Mark as spam',
), ),
findsOneWidget, findsNothing,
); );
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
}); });
testWidgets('Mark as spam shows dialog when no junk folder', testWidgets('Mark as spam shows dialog when no junk folder', (
(tester) async { tester,
) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole // FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown. // returns null → dialog shown.
await tester.pumpWidget( await tester.pumpWidget(
@@ -304,11 +309,11 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( // Open the popup menu first, then tap Mark as spam.
find.byWidgetPredicate( await tester.tap(find.byType(PopupMenuButton<String>));
(w) => w is Tooltip && w.message == 'Mark as spam', await tester.pumpAndSettle();
),
); await tester.tap(find.text('Mark as spam'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget); expect(find.text('No spam folder found'), findsOneWidget);
@@ -326,9 +331,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget, findsOneWidget,
); );
}); });
@@ -347,17 +350,16 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate( find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
(w) => w is Tooltip && w.message == 'Archive',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget); expect(find.text('No archive folder found'), findsOneWidget);
}); });
testWidgets('Mark as unread is in popup menu, not a standalone button', testWidgets('Mark as unread is in popup menu, not a standalone button', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -393,13 +395,16 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider mailboxRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeMailboxRepository()), FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
emailDetail: testEmail(), emailDetail: testEmail(),
emailBody: emailBody: const EmailBody(
const EmailBody(emailId: 'acc-1:42', attachments: []), emailId: 'acc-1:42',
attachments: [],
),
rawRfc822: rawContent, rawRfc822: rawContent,
), ),
), ),
@@ -428,13 +433,16 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider mailboxRepositoryProvider.overrideWithValue(
.overrideWithValue(FakeMailboxRepository()), FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
emailDetail: testEmail(), emailDetail: testEmail(),
emailBody: emailBody: const EmailBody(
const EmailBody(emailId: 'acc-1:42', attachments: []), emailId: 'acc-1:42',
attachments: [],
),
rawRfc822: 'Subject: test\r\n\r\nBody', rawRfc822: 'Subject: test\r\n\r\nBody',
), ),
), ),
@@ -475,43 +483,37 @@ void main() {
expect(find.text('Share'), findsOneWidget); expect(find.text('Share'), findsOneWidget);
}); });
testWidgets( testWidgets('long-press on unsubscribe chip shows URL tooltip', (
'long-press on unsubscribe chip shows URL tooltip', tester,
(tester) async { ) async {
final email = testEmail( final email = testEmail(
listUnsubscribeHeader: '<https://example.com/unsubscribe>', listUnsubscribeHeader: '<https://example.com/unsubscribe>',
); );
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: _overrides(
overrides: _overrides( body: const EmailBody(emailId: 'acc-1:42', attachments: []),
body: const EmailBody(emailId: 'acc-1:42', attachments: []), email: email,
email: email,
),
), ),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
expect(find.text('Unsubscribe'), findsOneWidget); expect(find.text('Unsubscribe'), findsOneWidget);
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate(
(w) => (w) => w is Tooltip && w.message == 'https://example.com/unsubscribe',
w is Tooltip && w.message == 'https://example.com/unsubscribe', ),
), findsOneWidget,
findsOneWidget, );
);
await tester.longPress(find.text('Unsubscribe')); await tester.longPress(find.text('Unsubscribe'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.text('https://example.com/unsubscribe'), findsOneWidget);
find.text('https://example.com/unsubscribe'), });
findsOneWidget,
);
},
);
testWidgets('Show Mail Structure opens dialog with MIME parts', ( testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester, tester,
@@ -555,36 +557,31 @@ void main() {
expect(find.textContaining('application/pdf'), findsOneWidget); expect(find.textContaining('application/pdf'), findsOneWidget);
}); });
testWidgets( testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', (
'Show Mail Structure shows snackbar when mimeTree is absent', tester,
(tester) async { ) async {
const body = EmailBody( const body = EmailBody(
emailId: 'acc-1:42', emailId: 'acc-1:42',
textBody: 'Hello', textBody: 'Hello',
attachments: [], attachments: [],
// mimeTree is null — not yet cached or not available. // mimeTree is null — not yet cached or not available.
); );
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: _overrides(body: body),
overrides: _overrides(body: body), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
await tester.tap(find.byType(PopupMenuButton<String>)); await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Show Mail Structure')); await tester.tap(find.text('Show Mail Structure'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.textContaining('Structure not available'), findsOneWidget);
find.textContaining('Structure not available'), });
findsOneWidget,
);
},
);
}); });
} }
@@ -51,9 +51,7 @@ List<Override> _overrides({
searchHistoryRepositoryProvider.overrideWithValue( searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(), FakeSearchHistoryRepository(),
), ),
syncLastErrorProvider.overrideWith( syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
(ref, _) => Stream.value(syncError),
),
]; ];
void main() { void main() {
@@ -122,9 +120,7 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides( overrides: _overrides(
searchResults: [ searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')],
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
), ),
), ),
); );
+46 -47
View File
@@ -430,63 +430,62 @@ void main() {
expect(find.text('Result email'), findsWidgets); expect(find.text('Result email'), findsWidgets);
}); });
testWidgets( testWidgets('deleting all search results pops back to previous screen', (
'deleting all search results pops back to previous screen', tester,
(tester) async { ) async {
final email = testEmail(subject: 'Needle'); final email = testEmail(subject: 'Needle');
// Start at the mailbox list so the email list is pushed on top of it, // Start at the mailbox list so the email list is pushed on top of it,
// making context.canPop() == true inside EmailListScreen. // making context.canPop() == true inside EmailListScreen.
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes', initialLocation: '/accounts/acc-1/mailboxes',
overrides: [ overrides: [
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider.overrideWithValue( mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]), FakeMailboxRepository([kTestMailbox]),
), ),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]), FakeEmailRepository(searchResults: [email]),
), ),
], ],
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(MailboxListScreen), findsOneWidget); expect(find.byType(MailboxListScreen), findsOneWidget);
// Navigate into INBOX (pushes EmailListScreen onto the stack). // Navigate into INBOX (pushes EmailListScreen onto the stack).
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(EmailListScreen), findsOneWidget); expect(find.byType(EmailListScreen), findsOneWidget);
// Search for the email. // Search for the email.
await tester.enterText(find.byType(TextField), 'Needle'); await tester.enterText(find.byType(TextField), 'Needle');
await tester.testTextInput.receiveAction(TextInputAction.search); await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// 'Needle' also appears in the SearchBar input, so match at least one. // 'Needle' also appears in the SearchBar input, so match at least one.
expect(find.text('Needle'), findsAtLeastNWidgets(1)); expect(find.text('Needle'), findsAtLeastNWidgets(1));
// Long-press the sender name (unique to the email tile) to enter // Long-press the sender name (unique to the email tile) to enter
// selection mode. // selection mode.
await tester.longPress(find.text('Bob')); await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.select_all)); await tester.tap(find.byIcon(Icons.select_all));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete)); await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Should have popped back to the mailbox list. // Should have popped back to the mailbox list.
expect(find.byType(EmailListScreen), findsNothing); expect(find.byType(EmailListScreen), findsNothing);
expect(find.byType(MailboxListScreen), findsOneWidget); expect(find.byType(MailboxListScreen), findsOneWidget);
}, });
);
testWidgets( testWidgets(
'deleting some search results updates the list without popping', 'deleting some search results updates the list without popping',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+23 -3
View File
@@ -414,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
Widget buildApp({ Widget buildApp({
required String initialLocation, required String initialLocation,
required List<Override> overrides, required List<Override> overrides,
UserPreferencesRepository? userPreferences,
}) { }) {
final testRouter = GoRouter( final testRouter = GoRouter(
initialLocation: initialLocation, initialLocation: initialLocation,
@@ -523,7 +524,7 @@ Widget buildApp({
const NoOpSyncLogRepository(), const NoOpSyncLogRepository(),
), ),
userPreferencesRepositoryProvider.overrideWithValue( userPreferencesRepositoryProvider.overrideWithValue(
FakeUserPreferencesRepository(), userPreferences ?? FakeUserPreferencesRepository(),
), ),
...overrides, ...overrides,
manageSieveProbeServiceProvider.overrideWith( manageSieveProbeServiceProvider.overrideWith(
@@ -624,18 +625,37 @@ Email testEmail({
class FakeUserPreferencesRepository implements UserPreferencesRepository { class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({ FakeUserPreferencesRepository({
this.menuPosition = MenuPosition.bottom, this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
}); });
MenuPosition menuPosition; MenuPosition menuPosition;
MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction;
@override @override
Stream<UserPreferences> observePreferences() => Stream<UserPreferences> observePreferences() => Stream.value(
Stream.value(UserPreferences(menuPosition: menuPosition)); UserPreferences(
menuPosition: menuPosition,
mailViewButtonPosition: mailViewButtonPosition,
afterMailViewAction: afterMailViewAction,
),
);
@override @override
Future<void> updateMenuPosition(MenuPosition position) async { Future<void> updateMenuPosition(MenuPosition position) async {
menuPosition = position; menuPosition = position;
} }
@override
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
mailViewButtonPosition = position;
}
@override
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action;
}
} }
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
+2 -6
View File
@@ -89,9 +89,7 @@ void main() {
expect(find.text('No results'), findsOneWidget); expect(find.text('No results'), findsOneWidget);
}); });
testWidgets('shows email results under "Messages" section', ( testWidgets('shows email results under "Messages" section', (tester) async {
tester,
) async {
final email = testEmail(subject: 'Invoice Q3'); final email = testEmail(subject: 'Invoice Q3');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
@@ -122,9 +120,7 @@ void main() {
expect(find.text('Invoice Q3'), findsOneWidget); expect(find.text('Invoice Q3'), findsOneWidget);
}); });
testWidgets('shows folder results under "Folders" section', ( testWidgets('shows folder results under "Folders" section', (tester) async {
tester,
) async {
const archiveMailbox = Mailbox( const archiveMailbox = Mailbox(
id: 'acc-1:Archive', id: 'acc-1:Archive',
accountId: 'acc-1', accountId: 'acc-1',
+15 -19
View File
@@ -20,10 +20,12 @@ Widget _wrap(Widget child) => MaterialApp(
void main() { void main() {
group('buildEmailHtml', () { group('buildEmailHtml', () {
test('forces light color-scheme to prevent black-on-black in dark mode', test(
() { 'forces light color-scheme to prevent black-on-black in dark mode',
_expectLightMode(buildEmailHtml('<p>Hello</p>')); () {
}); _expectLightMode(buildEmailHtml('<p>Hello</p>'));
},
);
test('includes email body content', () { test('includes email body content', () {
final html = buildEmailHtml('<p>Test body</p>'); final html = buildEmailHtml('<p>Test body</p>');
@@ -44,8 +46,9 @@ void main() {
test('prevents horizontal overflow so wide HTML emails are not cut off', test('prevents horizontal overflow so wide HTML emails are not cut off',
() { () {
final html = final html = buildEmailHtml(
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>'); '<table width="600"><tr><td>x</td></tr></table>',
);
// Body clips overflow so fixed-width email tables don't escape the viewport. // Body clips overflow so fixed-width email tables don't escape the viewport.
expect(html, contains('overflow-x: hidden')); expect(html, contains('overflow-x: hidden'));
// Tables are forced to full viewport width so fixed pixel widths don't overflow. // Tables are forced to full viewport width so fixed pixel widths don't overflow.
@@ -62,11 +65,7 @@ void main() {
group('SecureEmailWebView (Linux plain-text fallback)', () { group('SecureEmailWebView (Linux plain-text fallback)', () {
testWidgets('renders extracted text from HTML', (tester) async { testWidgets('renders extracted text from HTML', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(const SecureEmailWebView(htmlBody: '<p>Hello <b>world</b></p>')),
const SecureEmailWebView(
htmlBody: '<p>Hello <b>world</b></p>',
),
),
); );
expect(find.textContaining('Hello'), findsOneWidget); expect(find.textContaining('Hello'), findsOneWidget);
expect(find.textContaining('world'), findsOneWidget); expect(find.textContaining('world'), findsOneWidget);
@@ -92,12 +91,11 @@ void main() {
expect(find.byType(SelectableText), findsOneWidget); expect(find.byType(SelectableText), findsOneWidget);
}); });
testWidgets('toggling loadRemoteImages rebuilds without error', testWidgets('toggling loadRemoteImages rebuilds without error', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(const SecureEmailWebView(htmlBody: '<p>Body</p>')),
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
),
); );
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(
@@ -111,9 +109,7 @@ void main() {
}); });
testWidgets('handles empty HTML body', (tester) async { testWidgets('handles empty HTML body', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(_wrap(const SecureEmailWebView(htmlBody: '')));
_wrap(const SecureEmailWebView(htmlBody: '')),
);
expect(find.byType(SelectableText), findsOneWidget); expect(find.byType(SelectableText), findsOneWidget);
}); });
}); });
+2 -6
View File
@@ -27,13 +27,9 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
sieveRepositoryProvider.overrideWith( sieveRepositoryProvider.overrideWith((ref) => _FakeSieveRepository()),
(ref) => _FakeSieveRepository(),
),
], ],
child: const MaterialApp( child: const MaterialApp(home: SieveScriptsScreen(accountId: 'acc-1')),
home: SieveScriptsScreen(accountId: 'acc-1'),
),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'helpers.dart'; import 'helpers.dart';
@@ -142,6 +143,60 @@ void main() {
expect(find.byIcon(Icons.expand_more), findsOneWidget); expect(find.byIcon(Icons.expand_more), findsOneWidget);
}); });
testWidgets('shows bottom app bar with back button by default', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
});
testWidgets('hides bottom app bar when button position is top', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
userPreferences: FakeUserPreferencesRepository(
mailViewButtonPosition: MenuPosition.top,
),
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsNothing);
});
testWidgets('flagged email shows star icon', (tester) async { testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true); final email = _threadEmail(isFlagged: true);
await tester.pumpWidget( await tester.pumpWidget(
+21 -16
View File
@@ -38,8 +38,9 @@ void main() {
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
timestamp: DateTime.now().subtract(const Duration(hours: 1)), timestamp: DateTime.now().subtract(const Duration(hours: 1)),
); );
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) when(
.thenAnswer((_) async => [staleAction]); mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => [staleAction]);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -48,10 +49,12 @@ void main() {
}, },
); );
testWidgets('shows snackbar for fresh action pushed in current session', testWidgets('shows snackbar for fresh action pushed in current session', (
(tester) async { tester,
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) ) async {
.thenAnswer((_) async => []); when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -64,18 +67,20 @@ void main() {
emailIds: ['e1'], emailIds: ['e1'],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
); );
await ProviderScope.containerOf(context) await ProviderScope.containerOf(
.read(undoServiceProvider.notifier) context,
.pushAction(freshAction); ).read(undoServiceProvider.notifier).pushAction(freshAction);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('1 email(s) moved'), findsOneWidget); expect(find.text('1 email(s) moved'), findsOneWidget);
}); });
testWidgets('shows correct text for delete action (moved to Trash)', testWidgets('shows correct text for delete action (moved to Trash)', (
(tester) async { tester,
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) ) async {
.thenAnswer((_) async => []); when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -88,9 +93,9 @@ void main() {
emailIds: ['e1', 'e2'], emailIds: ['e1', 'e2'],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
); );
await ProviderScope.containerOf(context) await ProviderScope.containerOf(
.read(undoServiceProvider.notifier) context,
.pushAction(deleteAction); ).read(undoServiceProvider.notifier).pushAction(deleteAction);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('2 email(s) moved to Trash'), findsOneWidget); expect(find.text('2 email(s) moved to Trash'), findsOneWidget);
+131 -8
View File
@@ -20,11 +20,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget); expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsOneWidget); expect(find.text('Bottom (default)'), findsNWidgets(2));
expect(find.text('Top'), findsOneWidget); expect(find.text('Top'), findsNWidgets(2));
}); });
testWidgets('bottom option is selected by default', (tester) async { testWidgets('shows single mail view button position section', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/preferences', initialLocation: '/accounts/preferences',
@@ -33,12 +35,12 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroup = find.byType(RadioGroup<MenuPosition>); expect(find.text('Single mail view button position'), findsOneWidget);
final widget = tester.widget<RadioGroup<MenuPosition>>(radioGroup);
expect(widget.groupValue, MenuPosition.bottom);
}); });
testWidgets('tapping Top option updates the repo', (tester) async { testWidgets('menu position bottom option is selected by default', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/preferences', initialLocation: '/accounts/preferences',
@@ -47,7 +49,43 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Top')); final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup = tester.widget<RadioGroup<MenuPosition>>(
radioGroups.first,
);
expect(menuGroup.groupValue, MenuPosition.bottom);
});
testWidgets('mail view button position bottom is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup = tester.widget<RadioGroup<MenuPosition>>(
radioGroups.last,
);
expect(mailViewGroup.groupValue, MenuPosition.bottom);
});
testWidgets('tapping Top in menu position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = ProviderScope.containerOf( final repo = ProviderScope.containerOf(
@@ -57,5 +95,90 @@ void main() {
expect(repo.menuPosition, MenuPosition.top); expect(repo.menuPosition, MenuPosition.top);
}); });
testWidgets(
'tapping Top in mail view button position section updates the repo',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top);
},
);
testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
// Scroll down to reveal the new section below the fold.
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('After mail action'), findsOneWidget);
expect(find.text('Next message (default)'), findsOneWidget);
expect(find.text('Return to mailbox'), findsOneWidget);
});
testWidgets('after mail action next message is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group = tester.widget<RadioGroup<AfterMailViewAction>>(
radioGroups.first,
);
expect(group.groupValue, AfterMailViewAction.nextMessage);
});
testWidgets('tapping Return to mailbox updates the repo', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
await tester.tap(find.text('Return to mailbox'));
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
});
}); });
} }