Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 069722ce2f fix: diff from last deployed SHA to catch all changes since last deploy (#320)
The check-changes job was only diffing HEAD~1..HEAD (the single most recent
commit). When CI-only commits landed after Android code changes, the deploy
was skipped every hour even though app code had changed since the last
successful Play Store publish.

Fix: fetch full history (fetch-depth: 0) and diff from LAST_DEPLOYED_SHA
when available, so all commits since the last deploy are considered. Also
simplify the Python workflow_id filter to match regardless of whether
Forgejo returns a bare filename or a full path. Fix duplicate entry in
check_coverage.dart exclusions and update the selection-mode golden that
was off by 4 pixels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:53:05 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 eba94f2aa7 fix: also retry dagger when exit code is 2 (engine connection failure)
dagger call --progress=plain -q writes its final error (e.g.
"invalid return status code") directly to the controlling terminal
rather than through stdout/stderr, so the DAGGER_OUT file that the
grep-based retry check reads ends up empty.  Add RC=2 as an
additional fallback condition so the retry triggers even when the
output-capture path misses the error message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:11:46 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e251c74139 fix: retry dagger on context deadline exceeded engine timeout
Add "context deadline exceeded" to the retry_dagger network-error
pattern so transient Dagger engine connection timeouts (10-min
BuildKit context deadline) trigger a retry instead of immediately
failing the full check suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:11:46 +02:00
Thomas SharedInbox 385c2234ee feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325)
Remove the publish-website job from deploy.yml and add an hourly cron
schedule to website.yml so the website deploys independently on its own
cadence. Also fix pre-existing golden test and coverage exclusions for
files introduced in #315.
2026-05-29 12:11:46 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 02b9635c83 feat: reimplement PR #307 — user preferences, archive, configurable navigation (#315)
Reimplements the changes from PR #307 (branch issue-299-fix) on top of
current main, resolving all merge conflicts.

Features added:
- User preferences system (DB schema v34–v36): menuPosition,
  mailViewButtonPosition, afterMailViewAction stored as a singleton row
- Preferences screen accessible from the account drawer
- Configurable menu bar position in mailbox view (bottom/top)
- Configurable back button position in single mail view (bottom/top)
- Configurable "after mail action": navigate to next message or return to
  mailbox after delete/archive/spam/move/snooze
- Archive button in email detail screen; resolveMailboxByRole helper
  prompts to choose or create a folder when none exists
- Improved Mark as spam: uses resolveMailboxByRole with dialog
- Show full discrepancy details in account list sync health row
- CSS fixes in SecureEmailWebView to prevent HTML email content overflow
- Preserve manually-set mailbox roles across IMAP syncs
- Tooltip on List-Unsubscribe chip showing the URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:11:35 +02:00
93 changed files with 3933 additions and 2348 deletions
+2 -6
View File
@@ -4,18 +4,14 @@
# In systemd service: # In systemd service:
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner # ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
# 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 # Infrastructure tools required by CI workflows
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
jq \ stunnel4 \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# SOPS
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
&& chmod +x /usr/local/bin/sops
# 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
+148 -3
View File
@@ -1,14 +1,159 @@
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
- name: Setup Dagger Remote Engine with:
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:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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
+61 -73
View File
@@ -34,17 +34,14 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD) HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent workflow run where deploy-playstore actually succeeded # Skip if this exact commit was already successfully deployed (prevents
# (not merely skipped). Bug fix: previous code used commit_sha (always None in # hourly schedule from redeploying the same commit on every tick).
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
# every run and the fallback diff to only cover HEAD~1..HEAD.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF' LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "") token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "") repo = os.environ.get("GITHUB_REPOSITORY", "")
base_api = f"{server}/api/v1/repos/{repo}/actions" url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try: try:
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req) as r:
@@ -53,58 +50,30 @@ jobs:
r for r in data.get("workflow_runs", []) r for r in data.get("workflow_runs", [])
if r.get("status") == "success" if r.get("status") == "success"
] ]
# Walk runs newest-first; pick the first one where deploy-playstore print(runs[0].get("commit_sha") or "")
# actually ran (conclusion=success), not just skipped.
for run in runs:
run_id = run.get("id")
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Deploy to Play Store" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
print("")
except Exception as e: except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})") print(f"API check failed: {e}", file=sys.stderr)
print("") print("")
PYEOF PYEOF
) )
if [ -z "$LAST_DEPLOYED_SHA" ]; then if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution" echo "HEAD $HEAD_SHA already successfully deployed — skipping"
echo "android=true" >> "$GITHUB_OUTPUT"
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
echo "android=false" >> "$GITHUB_OUTPUT" echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT" echo "linux=false" >> "$GITHUB_OUTPUT"
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Diff from the last successfully deployed commit to catch all changes since # Diff from the last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit. Deploy all targets when the # that deploy, not just the most recent commit. Falls back to HEAD~1 when
# SHA is not in local history (shallow clone or very old deploy). # LAST_DEPLOYED_SHA is unknown or not in local history.
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then 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" echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \ CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD) || git show --name-only --format= HEAD)
else else
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution" CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
echo "android=true" >> "$GITHUB_OUTPUT" || git show --name-only --format= HEAD)
echo "linux=true" >> "$GITHUB_OUTPUT"
exit 0
fi fi
echo "Changed files:" echo "Changed files:"
@@ -113,25 +82,13 @@ jobs:
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)' android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)' linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
if echo "$CHANGED" | grep -qE "$android_re"; then echo "$CHANGED" | grep -qE "$android_re" \
echo "android=true" >> "$GITHUB_OUTPUT" && echo "android=true" >> "$GITHUB_OUTPUT" \
echo "Android deploy: TRIGGERED (android-relevant files changed)" || echo "android=false" >> "$GITHUB_OUTPUT"
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "android=false" >> "$GITHUB_OUTPUT"
echo "Android deploy: SKIPPED (no android-relevant files changed)"
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
fi
if echo "$CHANGED" | grep -qE "$linux_re"; then echo "$CHANGED" | grep -qE "$linux_re" \
echo "linux=true" >> "$GITHUB_OUTPUT" && echo "linux=true" >> "$GITHUB_OUTPUT" \
echo "Linux deploy: TRIGGERED (linux-relevant files changed)" || echo "linux=false" >> "$GITHUB_OUTPUT"
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "linux=false" >> "$GITHUB_OUTPUT"
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
fi
deploy-playstore: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
@@ -149,23 +106,28 @@ 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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: Verify Play Store deployment - name: Cleanup TLS credentials
run: | if: always()
python3 -m venv /tmp/playstore-venv run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
deploy-apk: deploy-apk:
name: Build & Deploy APK to Server name: Build & Deploy APK to Server
@@ -183,17 +145,31 @@ 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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
@@ -211,17 +187,29 @@ 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
+12 -2
View File
@@ -58,18 +58,28 @@ 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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
@@ -0,0 +1,18 @@
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
+11 -2
View File
@@ -18,13 +18,22 @@ 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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
+17 -3
View File
@@ -26,18 +26,32 @@ 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 - name: Setup Dagger Remote Engine (via stunnel)
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} 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 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: ${{ env.WEBSITE_SSH_HOST }} SSH_HOST: ${{ secrets.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
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.44.1" "flutter": "3.44.0"
} }
+32 -27
View File
@@ -8,41 +8,46 @@ CLI tool `fgj` is available to query issues/PRs/actions.
## Issue Label Workflow ## Issue Label Workflow
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent: We use issues, follow this label state machine:
| Label | Trigger | Outcome | - **State/ToPlan** — Issue needs a plan written by an agent before implementation
|---|---|---| - **State/Planned** — Plan has been posted as a comment; awaiting human review
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` | - **State/Ready** — Issue is approved and ready for implementation
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` | - **State/InProgress** — Set while an agent (or human) is actively working
- **State/Question** — Agent hit a blocker or needs clarification
**State machine:** Full lifecycle:
``` ```
loop/plan loop/plan-in-progress → loop/plan-done State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
↘ NeedSupervisor (on failure) State/Planned → State/Ready (manual: human reviews the plan and approves)
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
loop/code → loop/code-in-progress loop/code-done State/InProgress → closed (automated: after PR is merged and CI passes)
↘ NeedSupervisor (on failure) any state → State/Question (automated or manual: when blocked)
``` ```
**Rules:** List open issues ready to pick up:
- 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
2. Add label loop/plan → agent writes plan as comment Rules:
3. Review plan, request changes or approve
4. Add label loop/code → agent implements + opens PR - Never start implementation on an issue without `State/Ready`
5. Review PR, merge - Planning agents only post a plan comment — they do NOT write code or open PRs
6. Close issue - After `State/Planned`, a human must review the plan and manually add `State/Ready`
``` - 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
+1 -1
View File
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
# Replace 1003 with the actual UID of dagger-svc # Replace 1003 with the actual UID of dagger-svc
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
Environment=XDG_RUNTIME_DIR=/run/user/1003 Environment=XDG_RUNTIME_DIR=/run/user/1003
ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080 ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
Restart=always Restart=always
[Install] [Install]
-2
View File
@@ -188,5 +188,3 @@ 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,8 +216,3 @@ 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
+7 -16
View File
@@ -218,7 +218,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS" - sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set" msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle: build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
@@ -247,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH" - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk: deploy-apk:
desc: Build and deploy Android APK via Dagger desc: Build and deploy Android APK via Dagger
@@ -261,7 +261,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set" msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)" - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website: publish-website:
desc: Build and publish website via Dagger desc: Build and publish website via Dagger
@@ -271,7 +271,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS" - sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set" msg: "SSH_KNOWN_HOSTS is not set"
cmds: cmds:
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" - dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
check-dagger: check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available) desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -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 deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; 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
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true 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,16 +319,7 @@ 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 until [ -s "$PORTFILE" ]; do sleep 0.05; done
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" \
+2 -4
View File
@@ -16,10 +16,8 @@ android {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlin { kotlinOptions {
compilerOptions { jvmTarget = JavaVersion.VERSION_17.toString()
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.13.2" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")
+27 -19
View File
@@ -7,8 +7,8 @@ require (
github.com/Khan/genqlient v0.8.1 github.com/Khan/genqlient v0.8.1
github.com/dagger/otel-go v1.43.0 github.com/dagger/otel-go v1.43.0
github.com/vektah/gqlparser/v2 v2.5.33 github.com/vektah/gqlparser/v2 v2.5.33
go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/trace v1.44.0 go.opentelemetry.io/otel/trace v1.43.0
) )
require ( require (
@@ -21,25 +21,33 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect github.com/sosodev/duration v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
go.opentelemetry.io/otel/log v0.20.0 // indirect go.opentelemetry.io/otel/log v0.17.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
-32
View File
@@ -43,65 +43,36 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
@@ -116,13 +87,10 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+14 -37
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.44.1"). From("ghcr.io/cirruslabs/flutter:3.41.6").
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"}).
@@ -338,12 +338,7 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
return dag.Container(). return dag.Container().
From("alpine:3.21"). From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}). WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
// Mount at a raw path so we can normalise before use: strip any CRLF line WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
// endings that appear when the key is stored or exported on Windows, which
// cause "error in libcrypto" in Alpine's LibreSSL-backed openssh.
WithMountedSecret("/root/.ssh/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithExec([]string{"sh", "-c",
"tr -d '\\r' < /root/.ssh/id_ed25519.raw > /root/.ssh/id_ed25519 && chmod 600 /root/.ssh/id_ed25519"}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}). WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519") WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
} }
@@ -485,18 +480,11 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute) ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel() defer cancel()
// Run cheap structural checks in parallel for faster fail detection. if _, err := m.CheckHygiene(ctx); err != nil {
var fastEg errgroup.Group return "Hygiene check failed", err
fastEg.Go(func() error { }
_, err := m.CheckHygiene(ctx) if _, err := m.CheckLayers(ctx); err != nil {
return err return "Layer check failed", err
})
fastEg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
if err := fastEg.Wait(); err != nil {
return "", err
} }
checkSetup := m.setup(m.checkSrc()) checkSetup := m.setup(m.checkSrc())
@@ -520,19 +508,16 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return coverage, err return coverage, err
} }
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
// sibling via context — which would surface as "context canceled" in dagger
// output and trigger spurious retries in check-dagger.
var testBackend, testIntegration string var testBackend, testIntegration string
var eg errgroup.Group eg, egCtx := errgroup.WithContext(ctx)
eg.Go(func() error { eg.Go(func() error {
var e error var e error
testBackend, e = m.TestBackend(ctx) testBackend, e = m.TestBackend(egCtx)
return e return e
}) })
eg.Go(func() error { eg.Go(func() error {
var e error var e error
testIntegration, e = m.TestIntegration(ctx) testIntegration, e = m.TestIntegration(egCtx)
return e return e
}) })
if err := eg.Wait(); err != nil { if err := eg.Wait(); err != nil {
@@ -574,8 +559,6 @@ func (m *Ci) BuildWebsite(
knownHosts *dagger.Secret, knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
// +optional
commitHash string,
) *dagger.Directory { ) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost) buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
@@ -583,13 +566,9 @@ func (m *Ci) BuildWebsite(
Include: []string{"website/"}, Include: []string{"website/"},
}).WithDirectory("website/content/builds", buildHistory) }).WithDirectory("website/content/builds", buildHistory)
hugo := m.Hugo(). return m.Hugo().
WithDirectory("/src", websiteSource). WithDirectory("/src", websiteSource).
WithWorkdir("/src/website") WithWorkdir("/src/website").
if commitHash != "" {
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
}
return hugo.
WithExec([]string{"hugo", "--minify"}). WithExec([]string{"hugo", "--minify"}).
Directory("public") Directory("public")
} }
@@ -601,10 +580,8 @@ func (m *Ci) PublishWebsite(
knownHosts *dagger.Secret, knownHosts *dagger.Secret,
sshUser string, sshUser string,
sshHost string, sshHost string,
// +optional
commitHash string,
) (string, error) { ) (string, error) {
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash) public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
return m.Deployer(sshKey, knownHosts). return m.Deployer(sshKey, knownHosts).
WithDirectory("/public", public). WithDirectory("/public", public).
@@ -902,7 +879,7 @@ func (m *Ci) Graph() string {
` + "```" + `mermaid ` + "```" + `mermaid
flowchart TD flowchart TD
subgraph dagger ["Dagger · Check pipeline"] subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:3.44.1 + NDK + apt + precache"] toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
pubGet["pubGetLayer\nflutter pub get"] pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"] codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
-12
View File
@@ -4,18 +4,6 @@ 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 = 37; const int dbSchemaVersion = 36;
@@ -5,8 +5,4 @@ abstract class UserPreferencesRepository {
Future<void> updateMenuPosition(MenuPosition position); Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position); Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action); Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
Future<void> removeTrustedImageSender(String senderEmail);
} }
@@ -92,9 +92,8 @@ class ShareEncryptionService {
) { ) {
if (!s.startsWith(_pubKeyPrefix)) return null; if (!s.startsWith(_pubKeyPrefix)) return null;
try { try {
final data = Uint8List.fromList( final data =
base64.decode(s.substring(_pubKeyPrefix.length)), Uint8List.fromList(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),
+2 -3
View File
@@ -108,9 +108,8 @@ class SieveInterpreter {
} }
bool _globMatch(String value, String pattern) { bool _globMatch(String value, String pattern) {
final regexStr = RegExp.escape( final regexStr =
pattern, RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value); return RegExp('^$regexStr\$').hasMatch(value);
} }
+9 -3
View File
@@ -466,7 +466,9 @@ class _Scanner {
String readTaggedArg() { String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord(); if (!isAtEnd && _src[_pos] == ':') return readWord();
throw SieveParseException('Expected tagged argument at position $_pos'); throw SieveParseException(
'Expected tagged argument at position $_pos',
);
} }
String? peekSizeUnit() { String? peekSizeUnit() {
@@ -478,7 +480,9 @@ class _Scanner {
String readDigits() { String readDigits() {
if (isAtEnd || !_isDigit(_src[_pos])) { if (isAtEnd || !_isDigit(_src[_pos])) {
throw SieveParseException('Expected number at position $_pos'); throw SieveParseException(
'Expected number at position $_pos',
);
} }
final start = _pos; final start = _pos;
while (!isAtEnd && _isDigit(_src[_pos])) { while (!isAtEnd && _isDigit(_src[_pos])) {
@@ -489,7 +493,9 @@ class _Scanner {
String readQuotedString() { String readQuotedString() {
if (_src[_pos] != '"') { if (_src[_pos] != '"') {
throw SieveParseException('Expected " at position $_pos'); throw SieveParseException(
'Expected " at position $_pos',
);
} }
_pos++; // skip opening quote _pos++; // skip opening quote
final buf = StringBuffer(); final buf = StringBuffer();
+4 -1
View File
@@ -35,7 +35,10 @@ 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("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'"); .replaceAll(
"src='cid:${bareCid.toLowerCase()}'",
"src='$dataUri'",
);
} }
return result; return result;
} }
-15
View File
@@ -307,17 +307,6 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId}; Set<Column> get primaryKey => {accountId, messageId};
} }
/// Senders for whom remote images are loaded automatically.
/// Per-device/per-user — not tied to any email account.
@DataClassName('ImageTrustedSenderRow')
class ImageTrustedSenders extends Table {
TextColumn get senderEmail => text()();
DateTimeColumn get addedAt => dateTime()();
@override
Set<Column> get primaryKey => {senderEmail};
}
/// App-wide user preferences, stored as a singleton row (id always 1). /// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow') @DataClassName('UserPreferencesRow')
class UserPreferences extends Table { class UserPreferences extends Table {
@@ -356,7 +345,6 @@ class UserPreferences extends Table {
LocalSieveApplied, LocalSieveApplied,
ShareKeys, ShareKeys,
UserPreferences, UserPreferences,
ImageTrustedSenders,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
@@ -623,9 +611,6 @@ class AppDatabase extends _$AppDatabase {
userPreferences.afterMailViewAction, userPreferences.afterMailViewAction,
); );
} }
if (from < 37) {
await m.createTable(imageTrustedSenders);
}
}, },
); );
} }
+16 -11
View File
@@ -9,9 +9,8 @@ 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( final rows = await (_db.select(_db.localSieveScripts)
_db.localSieveScripts, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
return rows return rows
.map( .map(
@@ -27,9 +26,10 @@ 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( final row = await (_db.select(_db.localSieveScripts)
_db.localSieveScripts, ..where(
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) (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,7 +44,9 @@ 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((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) ..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write( .write(
LocalSieveScriptsCompanion( LocalSieveScriptsCompanion(
name: Value(name), name: Value(name),
@@ -76,9 +78,10 @@ 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( await (_db.delete(_db.localSieveScripts)
_db.localSieveScripts, ..where(
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) (t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.go(); .go();
} }
@@ -89,7 +92,9 @@ 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((t) => t.id.equals(rowId) & t.accountId.equals(accountId))) ..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(const LocalSieveScriptsCompanion(isActive: Value(true))); .write(const LocalSieveScriptsCompanion(isActive: Value(true)));
}); });
} }
@@ -9,8 +9,11 @@ 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(this._db, this._accounts, {ImapConnectFn? imapConnect}) DraftRepositoryImpl(
: _imapConnect = imapConnect; this._db,
this._accounts, {
ImapConnectFn? imapConnect,
}) : _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -121,7 +124,10 @@ class DraftRepositoryImpl implements DraftRepository {
} }
} }
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async { Future<void> _syncWithServer(
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');
@@ -156,9 +162,8 @@ 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))).write( await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
DraftsCompanion(imapServerId: Value(uid)), .write(DraftsCompanion(imapServerId: Value(uid)));
);
} }
} }
@@ -156,7 +156,6 @@ 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.
@@ -238,12 +237,7 @@ 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.firstOrNull; final msg = fetch.messages.first;
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 =
@@ -331,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
], ],
'fetchHTMLBodyValues': true, 'fetchHTMLBodyValues': true,
'fetchTextBodyValues': true, 'fetchTextBodyValues': true,
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'], 'bodyProperties': [
'partId',
'type',
'name',
'size',
'subParts',
],
}, },
'0', '0',
], ],
@@ -1949,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
.getSingleOrNull(); .getSingleOrNull();
final inboxPath = inboxMailbox?.path ?? 'INBOX'; final inboxPath = inboxMailbox?.path ?? 'INBOX';
final alreadyApplied = await (_db.select( final alreadyApplied = await (_db.select(_db.localSieveApplied)
_db.localSieveApplied, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.get(); .get();
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet(); final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
@@ -2051,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(1)) ..limit(1))
.getSingleOrNull(); .getSingleOrNull();
if (destMailbox == null) { if (destMailbox == null) {
log( log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
);
return; return;
} }
destPath = destMailbox.path; destPath = destMailbox.path;
@@ -2811,13 +2808,11 @@ 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(emailRow.uid, 'BODY.PEEK[]'); final fetch = await client.uidFetchMessage(
final msg = fetch.messages.firstOrNull; emailRow.uid,
if (msg == null) { 'BODY.PEEK[]',
throw StateError( );
'IMAP server returned no message for UID ${emailRow.uid}.', final msg = fetch.messages.first;
);
}
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) {
@@ -2879,14 +2874,11 @@ 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(
final msg = fetch.messages.firstOrNull; emailRow.uid,
if (msg == null) { 'BODY.PEEK[]',
throw StateError( );
'IMAP server returned no message for UID ${emailRow.uid}.', return fetch.messages.first.renderMessage();
);
}
return msg.renderMessage();
} finally { } finally {
await client.logout(); await client.logout();
} }
@@ -2963,20 +2955,6 @@ class EmailRepositoryImpl implements EmailRepository {
}) async { }) async {
if (query.length < 2) return []; if (query.length < 2) return [];
final pattern = '%${query.toLowerCase()}%'; final pattern = '%${query.toLowerCase()}%';
// Addresses we deliberately wrote to (sent folder) should appear before
// addresses that happened to email us (inbox/other folders).
final sentMailboxes = await (_db.select(_db.mailboxes)
..where((t) {
Expression<bool> cond = t.role.equals('sent');
if (accountId != null) {
cond = t.accountId.equals(accountId) & cond;
}
return cond;
}))
.get();
final sentPaths = {for (final m in sentMailboxes) m.path};
final rows = await (_db.select(_db.emails) final rows = await (_db.select(_db.emails)
..where((t) { ..where((t) {
Expression<bool> cond = const Constant(true); Expression<bool> cond = const Constant(true);
@@ -2991,22 +2969,11 @@ class EmailRepositoryImpl implements EmailRepository {
..limit(100)) ..limit(100))
.get(); .get();
// Two passes: sent-folder rows first (prioritise recipients we chose),
// then other rows (senders who contacted us).
final sortedRows = [
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
];
final seen = <String>{}; final seen = <String>{};
final results = <model.EmailAddress>[]; final results = <model.EmailAddress>[];
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
for (final row in sortedRows) { for (final row in rows) {
final isSent = sentPaths.contains(row.mailboxPath); for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
final fields = isSent
? [row.toAddresses, row.ccJson, row.fromJson]
: [row.fromJson, row.toAddresses, row.ccJson];
for (final jsonStr in fields) {
final list = jsonDecode(jsonStr) as List<dynamic>; final list = jsonDecode(jsonStr) as List<dynamic>;
for (final e in list) { for (final e in list) {
final map = e as Map<String, dynamic>; final map = e as Map<String, dynamic>;
@@ -3285,17 +3252,14 @@ 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( await (_db.delete(_db.emails)
_db.emails, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete( await (_db.delete(_db.pendingChanges)
_db.pendingChanges, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete( await (_db.delete(_db.syncStates)
_db.syncStates, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
}); });
} finally { } finally {
@@ -82,9 +82,8 @@ 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( final existingRows = await (_db.select(_db.mailboxes)
_db.mailboxes, ..where((t) => t.accountId.equals(account.id)))
)..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};
@@ -321,9 +320,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete( await (_db.delete(_db.mailboxes)
_db.mailboxes, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@@ -369,9 +367,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role), role: Value(role),
), ),
); );
final row = await (_db.select( final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
_db.mailboxes,
)..where((t) => t.id.equals(id)))
.getSingle(); .getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -423,9 +419,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
role: Value(role), role: Value(role),
), ),
); );
final row = await (_db.select( final row = await (_db.select(_db.mailboxes)
_db.mailboxes, ..where((t) => t.id.equals(dbId)))
)..where((t) => t.id.equals(dbId)))
.getSingle(); .getSingle();
return _toModel(row); return _toModel(row);
} }
@@ -24,9 +24,8 @@ 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( await (_db.delete(_db.searchHistoryEntries)
_db.searchHistoryEntries, ..where((t) => t.query.equals(trimmed)))
)..where((t) => t.query.equals(trimmed)))
.go(); .go();
await _db.into(_db.searchHistoryEntries).insert( await _db.into(_db.searchHistoryEntries).insert(
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
.get(); .get();
if (keepIds.isNotEmpty) { if (keepIds.isNotEmpty) {
await (_db.delete( await (_db.delete(_db.searchHistoryEntries)
_db.searchHistoryEntries, ..where((t) => t.id.isNotIn(keepIds)))
)..where((t) => t.id.isNotIn(keepIds)))
.go(); .go();
} }
}); });
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
await _pruneExpired(); await _pruneExpired();
final keyIdHex = _hex(keyId); final keyIdHex = _hex(keyId);
final row = await (_db.select( final row = await (_db.select(_db.shareKeys)
_db.shareKeys, ..where((t) => t.id.equals(keyIdHex)))
)..where((t) => t.id.equals(keyIdHex)))
.getSingleOrNull(); .getSingleOrNull();
if (row == null) return null; if (row == null) return null;
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
} }
Future<void> _pruneExpired() async { Future<void> _pruneExpired() async {
await (_db.delete( await (_db.delete(_db.shareKeys)
_db.shareKeys, ..where(
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()))) (t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
))
.go(); .go();
} }
@@ -11,9 +11,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
@override @override
Stream<pref.UserPreferences> observePreferences() { Stream<pref.UserPreferences> observePreferences() {
return (_db.select( return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
_db.userPreferences,
)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull() .watchSingleOrNull()
.map(_rowToModel); .map(_rowToModel);
} }
@@ -50,31 +48,6 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
); );
} }
@override
Stream<List<String>> observeTrustedImageSenders() {
return (_db.select(_db.imageTrustedSenders)
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
.watch()
.map((rows) => rows.map((r) => r.senderEmail).toList());
}
@override
Future<void> addTrustedImageSender(String senderEmail) async {
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
ImageTrustedSendersCompanion(
senderEmail: Value(senderEmail.toLowerCase()),
addedAt: Value(DateTime.now()),
),
);
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
await (_db.delete(_db.imageTrustedSenders)
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
.go();
}
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(
+10 -47
View File
@@ -101,9 +101,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>(( final searchHistoryRepositoryProvider =
ref, Provider<SearchHistoryRepository>((ref) {
) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider)); return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -136,10 +135,8 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>(( final isSyncingProvider =
ref, StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
accountId,
) {
return ref.watch(syncManagerProvider).watchSyncing(accountId); return ref.watch(syncManagerProvider).watchSyncing(accountId);
}); });
@@ -188,9 +185,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>( final undoServiceProvider =
UndoService.new, NotifierProvider<UndoService, List<UndoAction>>(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.
@@ -211,32 +207,8 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
repo.getEmailBody(_emailId), repo.getEmailBody(_emailId),
]); ]);
unawaited(repo.setFlag(_emailId, seen: true)); unawaited(repo.setFlag(_emailId, seen: true));
final header = results[0] as Email?;
if (header != null) {
unawaited(_prefetchNextEmailBody(repo, header));
}
return (results[0] as Email?, results[1] as EmailBody); return (results[0] as Email?, results[1] as EmailBody);
} }
Future<void> _prefetchNextEmailBody(
EmailRepository repo,
Email header,
) async {
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return;
final threads =
await repo.observeThreads(header.accountId, header.mailboxPath).first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(_emailId),
);
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
final nextId = threads[currentIndex + 1].latestEmailId;
await repo.getEmailBody(nextId);
}
} }
final accountByIdProvider = final accountByIdProvider =
@@ -260,21 +232,12 @@ final accountConnectionStatusProvider =
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>(( final userPreferencesRepositoryProvider =
ref, Provider<UserPreferencesRepository>((ref) {
) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider)); return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
}); });
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>(( final userPreferencesProvider =
ref, StreamProvider.autoDispose<UserPreferences>((ref) {
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences(); return ref.watch(userPreferencesRepositoryProvider).observePreferences();
}); });
final trustedImageSendersProvider =
StreamProvider.autoDispose<List<String>>((ref) {
return ref
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
+7 -9
View File
@@ -72,10 +72,8 @@ 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 = await launchUrl( final launched =
url, await launchUrl(url, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -123,10 +121,8 @@ 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 = await launchUrl( final launched =
url, await launchUrl(url, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) { if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -180,7 +176,9 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
if (href != null) { if (href != null) {
unawaited(_launchUrl(context, Uri.parse(href))); unawaited(
_launchUrl(context, Uri.parse(href)),
);
} }
}, },
); );
+5 -1
View File
@@ -219,7 +219,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
), ),
), ),
_Step.done => const Center( _Step.done => const Center(
child: Icon(Icons.check_circle, size: 64, color: Colors.green), child: Icon(
Icons.check_circle,
size: 64,
color: Colors.green,
),
), ),
_Step.error => Center( _Step.error => Center(
child: Padding( child: Padding(
+7 -2
View File
@@ -158,7 +158,10 @@ 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(accountJson: account.toJson(), password: password), AccountPayload(
accountJson: account.toJson(),
password: password,
),
); );
} }
@@ -358,7 +361,9 @@ 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('Encrypted code copied to clipboard'), content: Text(
'Encrypted code copied to clipboard',
),
), ),
); );
}, },
+2 -3
View File
@@ -12,9 +12,8 @@ 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: DefaultAssetBundle.of( future:
context, DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
).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());
+9 -3
View File
@@ -194,7 +194,9 @@ 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(context).showSnackBar( ScaffoldMessenger.of(
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'),
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
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'),
@@ -251,7 +255,9 @@ 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(context).showSnackBar( ScaffoldMessenger.of(
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( style: Theme.of(context).textTheme.bodySmall?.copyWith(
context, color: Colors.grey[600],
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
+2 -3
View File
@@ -54,9 +54,8 @@ Future<Mailbox?> resolveMailboxByRole(
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
), ),
for (final m in mailboxes.where( for (final m
(m) => m.path != currentMailboxPath, in mailboxes.where((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),
+101 -73
View File
@@ -18,7 +18,6 @@ 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';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -73,7 +72,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_replyWithRecipientDialog(context, header, body)); unawaited(
_replyWithRecipientDialog(context, header, body),
);
}, },
), ),
IconButton( IconButton(
@@ -125,10 +126,22 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(value: 'forward', child: Text('Forward')), const PopupMenuItem(
const PopupMenuItem(value: 'move', child: Text('Move to folder')), value: 'forward',
const PopupMenuItem(value: 'snooze', child: Text('Snooze')), child: Text('Forward'),
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')), ),
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'),
@@ -142,7 +155,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
value: 'structure', value: 'structure',
child: Text('Show Mail Structure'), child: Text('Show Mail Structure'),
), ),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')), const PopupMenuItem(
value: 'rfc',
child: Text('Show Raw Email'),
),
], ],
onSelected: (value) async { onSelected: (value) async {
if (value == 'forward' && header != null) { if (value == 'forward' && header != null) {
@@ -171,35 +187,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body: detail.when( body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')), error: (e, _) => Center(child: Text('Error: $e')),
data: (d) { data: (d) => _buildBody(context, d.$1, d.$2),
final trusted =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
return _buildBody(context, d.$1, d.$2, trusted);
},
), ),
); );
} }
Widget _buildBody( Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
BuildContext ctx,
Email? header,
EmailBody body,
List<String> trustedSenders,
) {
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final senderEmail = header?.from.isNotEmpty == true
? header!.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (header != null) ...[_buildHeader(ctx, header), const Divider()], if (header != null) ...[_buildHeader(ctx, header), const Divider()],
if (hasHtml) ...[ if (hasHtml) ...[
if (!effectiveLoadImages) if (!_loadRemoteImages)
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
@@ -207,40 +207,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.image_outlined, size: 18), icon: const Icon(Icons.image_outlined, size: 18),
label: const Text('Load remote images'), label: const Text('Load remote images'),
onPressed: () { onPressed: () => setState(() => _loadRemoteImages = true),
setState(() => _loadRemoteImages = true);
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
);
}
},
),
),
);
}
},
), ),
), ),
), ),
SecureEmailWebView( SecureEmailWebView(
htmlBody: body.htmlBody!, htmlBody: body.htmlBody!,
loadRemoteImages: effectiveLoadImages, loadRemoteImages: _loadRemoteImages,
), ),
] else ] else
SelectableText( SelectableText(
@@ -291,9 +264,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
.observeThreads(header.accountId, header.mailboxPath) .observeThreads(header.accountId, header.mailboxPath)
.first; .first;
final currentIndex = threads.indexWhere( final currentIndex =
(t) => t.emailIds.contains(widget.emailId), threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
);
if (currentIndex >= 0 && currentIndex + 1 < threads.length) { if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId; return threads[currentIndex + 1].latestEmailId;
} }
@@ -548,7 +520,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: {'prefillSubject': subject, 'prefillBody': quoted}, extra: {
'prefillSubject': subject,
'prefillBody': quoted,
},
), ),
); );
} }
@@ -650,9 +625,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( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Failed to fetch raw email: $e')),
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e'))); );
return; return;
} }
@@ -766,7 +741,47 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (ctx) => EmailHeadersDialog(headers: body.headers), builder: (ctx) => AlertDialog(
title: const Text('Mail Headers'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: body.headers.length,
itemBuilder: (ctx, i) {
final header = body.headers[i];
return Container(
color: i.isEven
? Theme.of(ctx).colorScheme.surfaceContainerHighest
: Theme.of(ctx).colorScheme.surface,
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
), ),
); );
} }
@@ -777,7 +792,9 @@ 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('Structure not available. Try re-syncing the email.'), content: Text(
'Structure not available. Try re-syncing the email.',
),
), ),
); );
return; return;
@@ -789,13 +806,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited( unawaited(
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (ctx) => Dialog.fullscreen( builder: (ctx) => AlertDialog(
child: Scaffold( title: const Text('Mail Structure'),
appBar: AppBar( content: SizedBox(
title: const Text('Mail Structure'), width: double.maxFinite,
leading: const CloseButton(), child: ListView.builder(
), shrinkWrap: true,
body: ListView.builder(
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final row = rows[i]; final row = rows[i];
@@ -824,6 +840,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}, },
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
), ),
), ),
); );
@@ -881,8 +903,14 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
SegmentedButton<_Placement>( SegmentedButton<_Placement>(
showSelectedIcon: false, showSelectedIcon: false,
segments: const [ segments: const [
ButtonSegment(value: _Placement.to, label: Text('To')), ButtonSegment(
ButtonSegment(value: _Placement.cc, label: Text('Cc')), value: _Placement.to,
label: Text('To'),
),
ButtonSegment(
value: _Placement.cc,
label: Text('Cc'),
),
ButtonSegment( ButtonSegment(
value: _Placement.skip, value: _Placement.skip,
label: Text('Skip'), label: Text('Skip'),
+8 -3
View File
@@ -381,7 +381,11 @@ 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(error, maxLines: 2, overflow: TextOverflow.ellipsis), content: Text(
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,
@@ -395,8 +399,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
child: const Text('Retry'), child: const Text('Retry'),
), ),
TextButton( TextButton(
onPressed: () => onPressed: () => context.push(
context.push('/accounts/${widget.accountId}/sync-log'), '/accounts/${widget.accountId}/sync-log',
),
child: const Text('View log'), child: const Text('View log'),
), ),
TextButton( TextButton(
+2 -3
View File
@@ -10,9 +10,8 @@ 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 = FutureProvider.autoDispose<List<String>>(( final _searchHistoryProvider =
ref, FutureProvider.autoDispose<List<String>>((ref) async {
) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches(); return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
}); });
+3 -1
View File
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'), title: Text(
widget.isLocal ? 'Local Filters' : 'Remote Filters',
),
), ),
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
+6 -52
View File
@@ -113,14 +113,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final trustedSenders =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
final senderEmail = widget.email.from.isNotEmpty
? widget.email.from.first.email.toLowerCase()
: null;
final isTrusted =
senderEmail != null && trustedSenders.contains(senderEmail);
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@@ -155,13 +147,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
], ],
), ),
), ),
if (_expanded) _buildExpandedBody(isTrusted, senderEmail), if (_expanded) _buildExpandedBody(),
], ],
), ),
); );
} }
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) { Widget _buildExpandedBody() {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column( child: Column(
@@ -171,17 +163,6 @@ 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(
@@ -192,48 +173,21 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
} }
final body = snapshot.data!; final body = snapshot.data!;
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
final effectiveLoadImages = _loadRemoteImages || isTrusted;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (hasHtml) ...[ if (hasHtml) ...[
if (!effectiveLoadImages) if (!_loadRemoteImages)
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.image_outlined, size: 16), icon: const Icon(Icons.image_outlined, size: 16),
label: const Text('Load remote images'), label: const Text('Load remote images'),
onPressed: () { onPressed: () =>
setState(() => _loadRemoteImages = true); setState(() => _loadRemoteImages = true),
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
);
}
},
),
),
);
}
},
), ),
SecureEmailWebView( SecureEmailWebView(
htmlBody: body.htmlBody!, htmlBody: body.htmlBody!,
loadRemoteImages: effectiveLoadImages, loadRemoteImages: _loadRemoteImages,
), ),
] else ] else
SelectableText( SelectableText(
+3 -1
View File
@@ -84,7 +84,9 @@ 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(context).showSnackBar( ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), duration: Duration(seconds: 5),
content: Text('Action undone.'), content: Text('Action undone.'),
+9 -43
View File
@@ -12,7 +12,6 @@ class UserPreferencesScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider); final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Preferences')), appBar: AppBar(title: const Text('Preferences')),
@@ -91,7 +90,9 @@ class UserPreferencesScreen extends ConsumerWidget {
), ),
RadioListTile<MenuPosition>( RadioListTile<MenuPosition>(
title: Text('Top'), title: Text('Top'),
subtitle: Text('Show the back button in the top bar.'), subtitle: Text(
'Show the back button in the top bar.',
),
value: MenuPosition.top, value: MenuPosition.top,
), ),
], ],
@@ -121,56 +122,21 @@ class UserPreferencesScreen extends ConsumerWidget {
children: [ children: [
RadioListTile<AfterMailViewAction>( RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'), title: Text('Next message (default)'),
subtitle: Text('Show the next message in the mailbox.'), subtitle: Text(
'Show the next message in the mailbox.',
),
value: AfterMailViewAction.nextMessage, value: AfterMailViewAction.nextMessage,
), ),
RadioListTile<AfterMailViewAction>( RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'), title: Text('Return to mailbox'),
subtitle: Text('Return to the message list.'), subtitle: Text(
'Return to the message list.',
),
value: AfterMailViewAction.showMailbox, value: AfterMailViewAction.showMailbox,
), ),
], ],
), ),
), ),
const Divider(),
ListTile(
title: Text(
'Trusted image senders',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Remote images are loaded automatically for these senders.',
),
),
...trustedSendersAsync.when(
loading: () => const [],
error: (_, __) => const [],
data: (senders) => senders.isEmpty
? [
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No trusted senders yet.'),
),
]
: [
for (final sender in senders)
ListTile(
title: Text(sender),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
),
],
),
], ],
), ),
), ),
+2 -3
View File
@@ -26,9 +26,8 @@ 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 = MediaQuery.of( final textScale =
context, MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
).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'
-258
View File
@@ -1,258 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
/// Full-screen dialog for browsing email headers, organised into groups.
class EmailHeadersDialog extends StatelessWidget {
const EmailHeadersDialog({super.key, required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: const Text('Mail Headers'),
leading: const CloseButton(),
),
body: _HeadersBody(headers: headers),
),
);
}
}
class _HeadersBody extends StatelessWidget {
const _HeadersBody({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final receivedHeaders = <EmailHeader>[];
final listHeaders = <EmailHeader>[];
final arcHeaders = <EmailHeader>[];
final otherHeaders = <EmailHeader>[];
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
final xByPrefix = <String, List<EmailHeader>>{};
for (final h in headers) {
final lower = h.name.toLowerCase();
if (lower == 'received') {
receivedHeaders.add(h);
continue;
}
if (lower.startsWith('list-')) {
listHeaders.add(h);
continue;
}
if (lower.startsWith('arc-')) {
arcHeaders.add(h);
continue;
}
if (lower.startsWith('x-')) {
final parts = h.name.split('-');
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
xByPrefix.putIfAbsent(prefix, () => []).add(h);
continue;
}
otherHeaders.add(h);
}
final sections = <Widget>[];
if (otherHeaders.isNotEmpty) {
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
}
if (listHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'List- Headers', headers: listHeaders),
);
}
if (receivedHeaders.isNotEmpty) {
sections.add(_ReceivedSection(headers: receivedHeaders));
}
if (arcHeaders.isNotEmpty) {
sections.add(
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
);
}
// X- headers at bottom, each prefix in its own collapsible group.
final sortedPrefixes = xByPrefix.keys.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
for (final prefix in sortedPrefixes) {
sections.add(
_HeadersSection(
title: '$prefix Headers',
headers: xByPrefix[prefix]!,
),
);
}
return ListView(children: sections);
}
}
class _HeadersSection extends StatelessWidget {
const _HeadersSection({required this.title, required this.headers});
final String title;
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
return ExpansionTile(
title: Text('$title (${headers.length})'),
children: [
for (var i = 0; i < headers.length; i++)
_HeaderRow(header: headers[i], index: i),
],
);
}
}
/// Received headers section — collapsed by default; shows inter-hop delays.
class _ReceivedSection extends StatelessWidget {
const _ReceivedSection({required this.headers});
final List<EmailHeader> headers;
@override
Widget build(BuildContext context) {
final entries = _buildEntries(headers);
return ExpansionTile(
title: Text('Received (${headers.length})'),
children: [
for (var i = 0; i < entries.length; i++) ...[
_HeaderRow(header: entries[i].header, index: i),
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
],
],
);
}
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
final timestamps =
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
return [
for (var i = 0; i < headers.length; i++)
_ReceivedEntry(
header: headers[i],
delay: _computeDelay(timestamps, i),
),
];
}
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
if (i >= timestamps.length - 1) return null;
final current = timestamps[i];
final next = timestamps[i + 1];
if (current == null || next == null) return null;
final d = current.difference(next);
return d.isNegative ? Duration.zero : d;
}
}
class _ReceivedEntry {
const _ReceivedEntry({required this.header, this.delay});
final EmailHeader header;
final Duration? delay;
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.header, required this.index});
final EmailHeader header;
final int index;
@override
Widget build(BuildContext context) {
final bg = index.isEven
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surface;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
header.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(flex: 2, child: SelectableText(header.value)),
],
),
);
}
}
class _DelayRow extends StatelessWidget {
const _DelayRow({required this.delay});
final Duration delay;
@override
Widget build(BuildContext context) {
final color = _delayColor(delay);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Icon(Icons.arrow_downward, size: 14, color: color),
const SizedBox(width: 4),
Text(
_formatDuration(delay),
style: TextStyle(
fontSize: 12,
color: color,
fontWeight:
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
),
),
],
),
);
}
}
/// Parses the RFC 2822 timestamp from a Received header value.
///
/// Received headers end with `; date`, e.g.:
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
DateTime? _parseReceivedTimestamp(String value) {
final semiIndex = value.lastIndexOf(';');
if (semiIndex < 0) return null;
var s = value.substring(semiIndex + 1).trim();
// Strip parenthesised comments like (UTC).
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
// Strip leading day-of-week abbreviation like "Mon, ".
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
// Collapse runs of whitespace.
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
for (final fmt in [
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
]) {
try {
return fmt.parse(s);
} catch (_) {}
}
return null;
}
String _formatDuration(Duration d) {
if (d.inSeconds < 60) return '${d.inSeconds}s';
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
}
Color _delayColor(Duration d) {
if (d.inSeconds < 30) return Colors.green;
if (d.inSeconds < 300) return Colors.orange;
return Colors.red;
}
+11 -17
View File
@@ -111,16 +111,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
Future<void> _measureHeight(String _) async { Future<void> _measureHeight(String _) async {
try { final result = await _controller!.runJavaScriptReturningResult(
final result = await _controller!.runJavaScriptReturningResult( 'document.documentElement.scrollHeight',
'document.documentElement.scrollHeight', );
); final h = double.tryParse(result.toString());
final h = double.tryParse(result.toString()); if (h != null && h > 0 && mounted) {
if (h != null && h > 0 && mounted) { setState(() => _height = h);
setState(() => _height = h);
}
} catch (_) {
// WebView not ready yet; height stays at default
} }
} }
@@ -191,14 +187,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final launched = await launchUrl( final launched =
uri, await launchUrl(uri, mode: LaunchMode.externalApplication);
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) { if (!launched && mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Could not open: $url')),
).showSnackBar(SnackBar(content: Text('Could not open: $url'))); );
} }
} }
} }
+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: ^0.20.2 intl: any
# 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
-23
View File
@@ -11,29 +11,6 @@
{ {
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"], "matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"] "addLabels": ["automerge"]
},
{
"matchManagers": ["gomod"],
"matchFileNames": ["ci/**"],
"enabled": false
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^\\.forgejo/Dockerfile$"],
"matchStrings": ["DAGGER_VERSION=(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)"],
"depNameTemplate": "dagger/dagger",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"fileMatch": ["^DAGGER\\.md$"],
"matchStrings": ["github:dagger/nix/v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)#dagger"],
"depNameTemplate": "dagger/dagger",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
} }
] ]
} }
+1135
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -62,7 +62,6 @@ const _excluded = {
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart', 'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart', 'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_headers_dialog.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart', 'lib/core/sync/background_sync.dart',
+89 -66
View File
@@ -1,79 +1,102 @@
#!/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 "${SOPS_AGE_KEY:-}" ]; then if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set." echo "Error: DAGGER_STUNNEL_URL must be set."
exit 1 exit 1
fi fi
echo "Decrypting secrets with SOPS..." # Parse host and port (e.g., example.com:8774 or just example.com)
export SOPS_AGE_KEY="$SOPS_AGE_KEY" host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
SECRETS_JSON=$(mktemp) port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
trap "rm -f $SECRETS_JSON" EXIT if [ "$host" == "$port" ]; then
port="8774"
fi
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON" MAX_PROBE_ATTEMPTS=5
PROBE_DELAY=30
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON") for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON") echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
if nc -zw 5 "$host" "$port" 2>/dev/null; then
# Export all CI secrets to the GitHub Actions environment so subsequent steps echo "Found active server on $host:$port"
# can use them without referencing Forgejo secrets directly. break
export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
if [ -n "${GITHUB_ENV:-}" ]; then
# Use heredoc syntax for multiline-safe export.
# Avoid adding a second trailing newline for values that already end with one
# (e.g. SSH private keys), which can corrupt PEM parsing.
{
printf '%s<<__EOF__\n' "$name"
printf '%s' "$value"
[ "${value%$'\n'}" = "$value" ] && printf '\n'
printf '__EOF__\n'
} >> "$GITHUB_ENV"
fi fi
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}" 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
export_secret "SSH_PRIVATE_KEY" # 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
export_secret "SSH_KNOWN_HOSTS" echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
export_secret "SSH_USER" if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
export_secret "SSH_HOST" _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
export_secret "WEBSITE_SSH_HOST" timeout 8 dagger version >/dev/null 2>&1; then
export_secret "PLAY_STORE_CONFIG_JSON" echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
export_secret "ANDROID_KEYSTORE_BASE64" if [ -n "${GITHUB_ENV:-}" ]; then
export_secret "ANDROID_KEYSTORE_PASSWORD" echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY" echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
export_secret "RENOVATE_FORGEJO_TOKEN" else
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
# Setup SSH directory and keys export _DAGGER_RUNNER_HOST="tcp://$host:$port"
mkdir -p ~/.ssh echo "Dagger configured at tcp://$host:$port (plain TCP)"
chmod 700 ~/.ssh fi
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key exit 0
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..."
# Verify the connection # 2b. Setup TLS credentials (passed as env vars from secrets)
echo "Verifying connection to Dagger engine via SSH tunnel..." mkdir -p /tmp/dagger-tls
# Use a simple command that doesn't require complex GraphQL operations. echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
echo "Error: Dagger engine unreachable via tunnel at localhost:8080" echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
# Debug chmod 600 /tmp/dagger-tls/client.key
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
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""Tests for verify_playstore_deploy.py."""
import os
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent))
import verify_playstore_deploy
def _make_session(version_code, track="internal"):
"""Return a mock AuthorizedSession with the given version code on the track."""
session = MagicMock()
edit_resp = MagicMock()
edit_resp.json.return_value = {"id": "edit-99"}
session.post.return_value = edit_resp
track_resp = MagicMock()
track_resp.json.return_value = {
"releases": [{"versionCodes": [str(version_code)], "status": "completed"}]
}
session.get.return_value = track_resp
session.delete.return_value = MagicMock()
return session
class TestMissingEnv(unittest.TestCase):
def test_missing_env_exits(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(SystemExit) as ctx:
verify_playstore_deploy.main()
self.assertEqual(ctx.exception.code, 1)
class TestRecentDeploy(unittest.TestCase):
def _run(self, version_code):
session = _make_session(version_code)
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
verify_playstore_deploy.main()
def test_recent_version_code_passes(self):
# Version code is Unix timestamp — a very recent one should pass.
recent_vc = int(time.time()) - 60 # 1 minute ago
self._run(recent_vc)
def test_old_version_code_fails(self):
old_vc = int(time.time()) - 7200 # 2 hours ago
with self.assertRaises(SystemExit) as ctx:
self._run(old_vc)
self.assertEqual(ctx.exception.code, 1)
class TestEmptyTrack(unittest.TestCase):
def _run_empty(self, releases):
session = MagicMock()
session.post.return_value = MagicMock(**{"json.return_value": {"id": "edit-1"}})
session.get.return_value = MagicMock(**{"json.return_value": {"releases": releases}})
session.delete.return_value = MagicMock()
with patch.dict(os.environ, {"PLAY_STORE_CONFIG_JSON": '{"type":"service_account"}'}):
with patch("verify_playstore_deploy.service_account.Credentials.from_service_account_info"):
with patch("verify_playstore_deploy.AuthorizedSession", return_value=session):
verify_playstore_deploy.main()
def test_no_releases_exits(self):
with self.assertRaises(SystemExit) as ctx:
self._run_empty([])
self.assertEqual(ctx.exception.code, 1)
def test_release_with_no_version_codes_exits(self):
with self.assertRaises(SystemExit) as ctx:
self._run_empty([{"status": "completed", "versionCodes": []}])
self.assertEqual(ctx.exception.code, 1)
if __name__ == "__main__":
unittest.main()
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""Verify that the Android app was recently published to the Play Store internal track.
The publish-android pipeline sets versionCode = int(time.Now().Unix()), so a
freshly deployed release always has a version code close to the current Unix
timestamp. This script queries the internal track and fails if the latest
version code is older than _MAX_DEPLOY_AGE_SECONDS, which would mean the
deployment silently did not land.
"""
import json
import os
import sys
import time
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
TRACK = "internal"
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
# Allow up to one hour for the build + upload to complete.
_MAX_DEPLOY_AGE_SECONDS = 3600
def main():
config_json = os.environ.get("PLAY_STORE_CONFIG_JSON")
if not config_json:
print("Error: PLAY_STORE_CONFIG_JSON environment variable not set", file=sys.stderr)
sys.exit(1)
creds = service_account.Credentials.from_service_account_info(
json.loads(config_json),
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
session = AuthorizedSession(creds)
# Open a read-only edit to query the current track state.
edit_resp = session.post(f"{_BASE}/{PACKAGE_NAME}/edits", json={}, timeout=30)
edit_resp.raise_for_status()
edit_id = edit_resp.json()["id"]
try:
track_resp = session.get(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
timeout=30,
)
track_resp.raise_for_status()
track_data = track_resp.json()
finally:
# Discard the edit — we made no changes.
try:
session.delete(f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}", timeout=30)
except Exception:
pass
releases = track_data.get("releases", [])
if not releases:
print(
f"ERROR: No releases found on {TRACK} track — deploy may have failed silently",
file=sys.stderr,
)
sys.exit(1)
all_version_codes = [
int(vc)
for release in releases
for vc in release.get("versionCodes", [])
]
if not all_version_codes:
print("ERROR: Latest release has no version codes", file=sys.stderr)
sys.exit(1)
latest_vc = max(all_version_codes)
now = int(time.time())
# versionCode is set to Unix timestamp by PublishAndroid in ci/main.go.
age_seconds = now - latest_vc
print(f"Latest version code on {TRACK} track: {latest_vc}")
print(f"Current time: {now} — version code age: {age_seconds}s")
if age_seconds > _MAX_DEPLOY_AGE_SECONDS:
print(
f"::error::Latest version code {latest_vc} is {age_seconds}s old "
f"(limit: {_MAX_DEPLOY_AGE_SECONDS}s). The deploy may have failed silently.",
file=sys.stderr,
)
sys.exit(1)
print(f"OK: version {latest_vc} verified on {TRACK} track ({age_seconds}s old)")
if __name__ == "__main__":
main()
-33
View File
File diff suppressed because one or more lines are too long
+49 -49
View File
@@ -20,67 +20,63 @@ 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( test('AccountSyncManager schedules IMAP sync for multiple accounts',
'AccountSyncManager schedules IMAP sync for multiple accounts', () async {
() async { final accounts = _FakeAccounts('pw');
final accounts = _FakeAccounts('pw'); final mailboxes = _FakeMailboxes();
final mailboxes = _FakeMailboxes(); final emails = _FakeEmails();
final emails = _FakeEmails(); final logs = _FakeLogs();
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( test('AccountSyncManager schedules JMAP sync for multiple accounts',
'AccountSyncManager schedules JMAP sync for multiple accounts', () async {
() async { final accounts = _FakeAccounts('pw');
final accounts = _FakeAccounts('pw'); final mailboxes = _FakeMailboxes();
final mailboxes = _FakeMailboxes(); final emails = _FakeEmails();
final emails = _FakeEmails(); final logs = _FakeLogs();
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(
@@ -175,7 +171,11 @@ class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{}; final syncCounts = <String, int>{};
@override @override
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) => Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
+49 -51
View File
@@ -566,61 +566,59 @@ void main() {
expect(pending.first.changeType, 'delete'); expect(pending.first.changeType, 'delete');
}); });
test( test('downloadAttachment fetches binary attachment bytes from IMAP',
'downloadAttachment fetches binary attachment bytes from IMAP', () async {
() async { final attachmentBytes = Uint8List.fromList(
final attachmentBytes = Uint8List.fromList( List.generate(32, (i) => i + 1),
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,
); );
const attachmentName = 'hello.bin'; await client.appendMessage(
const attachmentMime = 'application/octet-stream'; builder.buildMimeMessage(),
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,
); );
try { } finally {
final builder = MessageBuilder() await client.logout();
..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,15 +73,13 @@ abstract class AccountRepositoryContract {
expect(await repo.getPassword(_a.id), 'new'); expect(await repo.getPassword(_a.id), 'new');
}); });
test( test('removeAccount makes account disappear from observeAccounts',
'removeAccount makes account disappear from observeAccounts', () async {
() async { final repo = makeRepo();
final repo = makeRepo(); await repo.addAccount(_a, 'pw');
await repo.addAccount(_a, 'pw'); await repo.removeAccount(_a.id);
await repo.removeAccount(_a.id); expect(await repo.observeAccounts().first, isEmpty);
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();
+27 -24
View File
@@ -37,41 +37,44 @@ 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(String a, String m, {int limit = 50}) => Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
+8 -9
View File
@@ -9,13 +9,12 @@ 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);
}, });
);
} }
+2 -3
View File
@@ -86,9 +86,8 @@ 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 = RegExp( final match =
r'data:image/png;base64,([A-Za-z0-9+/=]+)', RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
).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));
+17 -4
View File
@@ -44,7 +44,10 @@ 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(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty); expect(
await repo.observeEmails(_account.id, 'INBOX').first,
isEmpty,
);
}); });
test('observeEmails emits inserted email', () async { test('observeEmails emits inserted email', () async {
@@ -58,7 +61,10 @@ 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(await repo.observeEmails(_account.id, 'Sent').first, isEmpty); expect(
await repo.observeEmails(_account.id, 'Sent').first,
isEmpty,
);
}); });
test('observeEmails orders by receivedAt descending', () async { test('observeEmails orders by receivedAt descending', () async {
@@ -110,7 +116,11 @@ 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(repo, id: 'er-acc:11', mailboxPath: 'INBOX'); await insertEmail(
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);
@@ -147,7 +157,10 @@ abstract class EmailRepositoryContract {
test('observeThreads starts empty', () async { test('observeThreads starts empty', () async {
final repo = await makeRepo(); final repo = await makeRepo();
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty); expect(
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
}); });
} }
} }
+199 -260
View File
@@ -453,103 +453,47 @@ void main() {
expect(results.first.subject, 'foobar baz'); expect(results.first.subject, 'foobar baz');
}); });
test( test('searchAddresses returns results sorted by most recently used',
'searchAddresses returns results sorted by most recently used', () async {
() async { final r = _makeRepos();
final r = _makeRepos(); await r.accounts.addAccount(_account, 'pw');
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( );
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:new', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:new',
mailboxPath: 'Sent', accountId: 'acc-1',
uid: 2, mailboxPath: 'Sent',
receivedAt: newer, uid: 2,
toAddresses: const Value( receivedAt: newer,
'[{"name":"Bob","email":"bob@example.com"}]', toAddresses: const Value(
), '[{"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(results.map((a) => a.email).toList(), [ expect(
'bob@example.com', results.map((a) => a.email).toList(),
'alice@example.com', ['bob@example.com', 'alice@example.com'],
]); );
}, });
);
test(
'searchAddresses prioritises sent-folder addresses over newer received',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Register the Sent mailbox so searchAddresses knows its role.
await r.db.into(r.db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
role: const Value('sent'),
),
);
// Older sent email: user deliberately wrote to info@foo.de.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:sent-1',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 1,
receivedAt: DateTime(2025),
toAddresses: const Value(
'[{"name":"Foo","email":"info@foo.de"}]',
),
),
);
// Newer received email: spam arrived today from info@spam.de.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:inbox-1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
receivedAt: DateTime(2026),
fromJson: const Value(
'[{"name":"Spam","email":"info@spam.de"}]',
),
),
);
// Even though spam is newer, the sent-folder address should win.
final results = await r.emails.searchAddresses(null, 'info');
expect(results.map((a) => a.email).toList(), [
'info@foo.de',
'info@spam.de',
]);
},
);
// ── IMAP method tests ──────────────────────────────────────────────────── // ── IMAP method tests ────────────────────────────────────────────────────
@@ -753,47 +697,47 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test( test('snooze flush selects src mailbox and moves email to Snoozed',
'snooze flush selects src mailbox and moves email to Snoozed', () async {
() async { final spy = SnoozeSpyImapClient();
final spy = SnoozeSpyImapClient(); final r = _makeRepos(
final r = _makeRepos(imapConnect: (_, __, ___) async => spy); imapConnect: (_, __, ___) async => spy,
await r.accounts.addAccount(_account, 'pw'); );
await r.db.into(r.db.emails).insert( await r.accounts.addAccount(_account, 'pw');
EmailsCompanion.insert( await r.db.into(r.db.emails).insert(
id: 'acc-1:5', EmailsCompanion.insert(
accountId: 'acc-1', id: 'acc-1:5',
mailboxPath: 'Snoozed', accountId: 'acc-1',
uid: 5, mailboxPath: 'Snoozed',
receivedAt: DateTime(2024), uid: 5,
), receivedAt: DateTime(2024),
); ),
await r.db.into(r.db.pendingChanges).insert( );
PendingChangesCompanion.insert( await r.db.into(r.db.pendingChanges).insert(
accountId: 'acc-1', PendingChangesCompanion.insert(
resourceType: 'Email', accountId: 'acc-1',
resourceId: 'acc-1:5', resourceType: 'Email',
changeType: 'snooze', resourceId: 'acc-1:5',
payload: jsonEncode({ changeType: 'snooze',
'uid': 5, payload: jsonEncode({
'src': 'INBOX', 'uid': 5,
'dest': 'Snoozed', 'src': 'INBOX',
'until': '2026-05-10T15:00:00.000', 'dest': 'Snoozed',
}), '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', () {
@@ -1696,123 +1640,119 @@ void main() {
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
}); });
test( test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed', () async {
() async { final List<Map<String, dynamic>> capturedBodies = [];
final List<Map<String, dynamic>> capturedBodies = []; final client = MockClient((req) async {
final client = MockClient((req) async { if (req.url.path.contains('well-known')) {
if (req.url.path.contains('well-known')) { return http.Response(
return http.Response( jsonEncode({
jsonEncode({ 'apiUrl': 'https://jmap.example.com/api/',
'apiUrl': 'https://jmap.example.com/api/', 'accounts': {
'accounts': { 'acct1': {'name': 'alice@example.com', 'isPersonal': true},
'acct1': {'name': 'alice@example.com', 'isPersonal': true}, },
}, 'primaryAccounts': {
'primaryAccounts': { 'urn:ietf:params:jmap:core': 'acct1',
'urn:ietf:params:jmap:core': 'acct1', 'urn:ietf:params:jmap:mail': 'acct1',
'urn:ietf:params:jmap:mail': 'acct1', },
}, 'capabilities': {},
'capabilities': {}, 'username': 'alice@example.com',
'username': 'alice@example.com', 'state': 'sess1',
'state': 'sess1', }),
}), 200,
200, );
); }
} final body = jsonDecode(req.body) as Map<String, dynamic>;
final body = jsonDecode(req.body) as Map<String, dynamic>; capturedBodies.add(body);
capturedBodies.add(body); final calls = body['methodCalls'] as List;
final calls = body['methodCalls'] as List; final methodName = (calls.first as List)[0] as String;
final methodName = (calls.first as List)[0] as String; if (methodName == 'Mailbox/set') {
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': [
[ [
'Email/set', 'Mailbox/set',
{'accountId': 'acct1', 'updated': {}}, {
'accountId': 'acct1',
'created': {
'new-snoozed': {'id': 'mbx-snoozed'},
},
},
'0', '0',
], ],
], ],
}), }),
200, 200,
); );
}); }
return http.Response(
final r = _makeRepos(httpClient: client); jsonEncode({
await seedChange( 'sessionState': 's1',
r.db, 'methodResponses': [
r.accounts, [
changeType: 'snooze', 'Email/set',
payload: jsonEncode({ {'accountId': 'acct1', 'updated': {}},
'uid': 0, '0',
'src': 'mbx-inbox', ],
'dest': 'Snoozed', ],
'until': '2026-05-10T15:00:00.000',
}), }),
200,
); );
});
await r.emails.flushPendingChanges('jmap-1', 'pw'); final r = _makeRepos(httpClient: client);
await seedChange(
r.db,
r.accounts,
changeType: 'snooze',
payload: jsonEncode({
'uid': 0,
'src': 'mbx-inbox',
'dest': 'Snoozed',
'until': '2026-05-10T15:00:00.000',
}),
);
// Change successfully applied — removed from queue. await r.emails.flushPendingChanges('jmap-1', 'pw');
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// First API call should be Mailbox/set to create the Snoozed folder. // Change successfully applied — removed from queue.
expect(capturedBodies, hasLength(2)); expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
final firstCall =
((capturedBodies.first['methodCalls'] as List).first as List)[0];
expect(firstCall, 'Mailbox/set');
// Second call should be Email/set using the newly created mailbox ID. // First API call should be Mailbox/set to create the Snoozed folder.
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first expect(capturedBodies, hasLength(2));
as List)[1] as Map<String, dynamic>; final firstCall =
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1'] ((capturedBodies.first['methodCalls'] as List).first as List)[0];
as Map<String, dynamic>; expect(firstCall, 'Mailbox/set');
expect(update['mailboxIds/mbx-snoozed'], true);
},
);
test( // Second call should be Email/set using the newly created mailbox ID.
'snooze uses existing mailbox ID when dest is already a JMAP ID', final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
() async { as List)[1] as Map<String, dynamic>;
final r = _makeRepos(httpClient: mockFlush(200)); final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
await seedChange( as Map<String, dynamic>;
r.db, expect(update['mailboxIds/mbx-snoozed'], true);
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'); test('snooze uses existing mailbox ID when dest is already a JMAP ID',
() 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',
}),
);
// Change applied without needing Mailbox/set (dest was already a valid ID). await r.emails.flushPendingChanges('jmap-1', 'pw');
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', () {
@@ -2342,42 +2282,41 @@ 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,7 +61,10 @@ 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(await repo.findMailboxByRole(_account.id, 'archive'), isNull); expect(
await repo.findMailboxByRole(_account.id, 'archive'),
isNull,
);
}); });
test('findMailboxByRole returns the matching mailbox', () async { test('findMailboxByRole returns the matching mailbox', () async {
+67 -69
View File
@@ -486,11 +486,8 @@ void main() {
); );
await r.accounts.addAccount(_jmapAccount, 'pw'); await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes.createMailboxWithRole( final result = await r.mailboxes
'jmap-1', .createMailboxWithRole('jmap-1', 'Archive', 'archive');
'Archive',
'archive',
);
expect(result.name, 'Archive'); expect(result.name, 'Archive');
expect(result.role, 'archive'); expect(result.role, 'archive');
@@ -501,80 +498,81 @@ void main() {
expect(found!.name, 'Archive'); expect(found!.name, 'Archive');
}); });
test('JMAP: throws when server returns no created ID', () async { test(
final r = _makeRepos( 'JMAP: throws when server returns no created ID',
httpClient: _mockJmap( () async {
apiResponses: [ final r = _makeRepos(
{ httpClient: _mockJmap(
'sessionState': 'sess1', apiResponses: [
'methodResponses': [ {
[ 'sessionState': 'sess1',
'Mailbox/set', 'methodResponses': [
{ [
'accountId': 'acct1', 'Mailbox/set',
'created': null, {
'notCreated': { 'accountId': 'acct1',
'new-mailbox': {'type': 'serverFail'}, 'created': null,
'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( test('existing role is kept when server returns no special-use flag',
'existing role is kept when server returns no special-use flag', () async {
() async { final spy = SnoozeSpyImapClient();
final spy = SnoozeSpyImapClient(); // Make listMailboxes return a plain folder without \Archive.
// Make listMailboxes return a plain folder without \Archive. final db = openTestDatabase();
final db = openTestDatabase(); final accounts = AccountRepositoryImpl(db, MapSecureStorage());
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);
}, });
);
}); });
}); });
} }
+80 -96
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, 37); 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('SELECT mime_tree_json FROM email_bodies LIMIT 0') .customSelect(
'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 = await _tableColumns( final syncLogMailboxColumns =
db, await _tableColumns(db, 'sync_log_mailboxes');
'sync_log_mailboxes',
);
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
@@ -209,22 +209,19 @@ void main() {
// v36: after_mail_view_action column on user_preferences. // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action')); expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
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,
@@ -245,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,
@@ -257,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,
@@ -268,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,
@@ -292,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,
@@ -309,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,
@@ -319,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,
@@ -336,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,
@@ -346,86 +343,79 @@ 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 = final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
allIndexes.map((r) => r.read<String>('name')).toSet(); expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('mailboxes_account_id')); expect(indexNames, contains('threads_latest_date'));
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('SELECT mime_tree_json FROM email_bodies LIMIT 0') .customSelect(
.get(); 'SELECT mime_tree_json FROM email_bodies LIMIT 0',
)
.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 = await _tableColumns( final syncLogMailboxColumns =
db, await _tableColumns(db, 'sync_log_mailboxes');
'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.
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();
// v35: mail_view_button_position column on user_preferences. // v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences'); final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position')); expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences. // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action')); expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table. await db.close();
await db if (dbFile.existsSync()) dbFile.deleteSync();
.customSelect('SELECT count(*) FROM image_trusted_senders') });
.get();
await db.close(); test('fresh install creates all tables at schemaVersion 36', () async {
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 37', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -453,7 +443,6 @@ void main() {
'share_keys', // v31 'share_keys', // v31
'local_sieve_applied', // v32 'local_sieve_applied', // v32
'user_preferences', // v34 'user_preferences', // v34
'image_trusted_senders', // v37
]), ]),
); );
@@ -464,10 +453,8 @@ 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 = await _tableColumns( final syncLogMailboxColumns =
db, await _tableColumns(db, 'sync_log_mailboxes');
'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.
@@ -482,9 +469,6 @@ void main() {
// v36: after_mail_view_action column on user_preferences. // v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action')); expect(userPrefsColumns, contains('after_mail_view_action'));
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
await db.close(); await db.close();
}); });
}); });
+8 -9
View File
@@ -9,15 +9,14 @@ 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
+10 -4
View File
@@ -26,9 +26,11 @@ 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 => accounts Future<Account?> getAccount(String id) async =>
.cast<Account?>() accounts.cast<Account?>().firstWhere(
.firstWhere((a) => a?.id == id, orElse: () => null); (a) => a?.id == id,
orElse: () => null,
);
@override @override
Future<void> addAccount(Account account, String password) async {} Future<void> addAccount(Account account, String password) async {}
@override @override
@@ -92,7 +94,11 @@ 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(String a, String m, {int limit = 50}) => Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
+3 -1
View File
@@ -47,7 +47,9 @@ 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('sharedinbox.de:pubkey:v1:!!!'), ShareEncryptionService.parsePublicKeyQr(
'sharedinbox.de:pubkey:v1:!!!',
),
isNull, isNull,
); );
expect( expect(
+7 -5
View File
@@ -73,7 +73,11 @@ void main() {
SieveRule( SieveRule(
joinType: 'single', joinType: 'single',
conditions: [ conditions: [
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']), HeaderCondition(
['from', 'reply-to'],
':is',
['boss@work.com'],
),
], ],
actions: [ actions: [
FlagAction([r'\Important']), FlagAction([r'\Important']),
@@ -117,10 +121,8 @@ void main() {
), ),
]; ];
final ctx = interp.execute( final ctx =
rules, interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
_email(subject: 'Weekly Newsletter Issue'),
);
expect(ctx.targetFolders, contains('Bulk')); expect(ctx.targetFolders, contains('Bulk'));
}); });
}); });
+2 -3
View File
@@ -261,9 +261,8 @@ 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 = parser.parse( final rules =
'if header :contains "Subject" "x" { discard; }', parser.parse('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));
+27 -29
View File
@@ -127,35 +127,33 @@ void main() {
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
}); });
test( test('stores and retrieves stackTrace and isPermanent on error entries',
'stores and retrieves stackTrace and isPermanent on error entries', () async {
() async { final repo = SyncLogRepositoryImpl(db);
final repo = SyncLogRepositoryImpl(db); final start = DateTime(2024, 3, 1, 9);
final start = DateTime(2024, 3, 1, 9); final end = DateTime(2024, 3, 1, 9, 0, 1);
final end = DateTime(2024, 3, 1, 9, 0, 1); const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
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');
}, });
);
} }
+4 -6
View File
@@ -260,9 +260,8 @@ 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))).write( await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
const EmailsCompanion(messageId: Value('msg-101@test')), .write(const EmailsCompanion(messageId: Value('msg-101@test')));
);
final originalWithMsgId = await repo.getEmail(oldEmailId); final originalWithMsgId = await repo.getEmail(oldEmailId);
@@ -304,9 +303,8 @@ 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( final inInbox = await (db.select(db.emails)
db.emails, ..where((t) => t.mailboxPath.equals('INBOX')))
)..where((t) => t.mailboxPath.equals('INBOX')))
.get(); .get();
expect( expect(
inInbox, inInbox,
+64 -64
View File
@@ -122,74 +122,70 @@ void main() {
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
}); });
test( test('undo pushes inverse action into log when destinationMailboxPath is set',
'undo pushes inverse action into log when destinationMailboxPath is set', () async {
() async { final action = UndoAction(
final action = UndoAction( id: 'del1',
id: 'del1', accountId: 'acc1',
accountId: 'acc1', type: UndoType.delete,
type: UndoType.delete, emailIds: ['e1'],
emailIds: ['e1'], sourceMailboxPath: 'INBOX',
sourceMailboxPath: 'INBOX', destinationMailboxPath: 'Trash',
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( test('undo without destinationMailboxPath does not push inverse action',
'undo without destinationMailboxPath does not push inverse action', () async {
() async { final action = UndoAction(
final action = UndoAction( id: 'mv1',
id: 'mv1', accountId: 'acc1',
accountId: 'acc1', type: UndoType.move,
type: UndoType.move, emailIds: ['e1'],
emailIds: ['e1'], sourceMailboxPath: 'INBOX',
sourceMailboxPath: 'INBOX', // no destinationMailboxPath
// 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
@@ -354,9 +350,13 @@ void main() {
); );
// Simulate slow DB load // Simulate slow DB load
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer( when(
(_) => mockUndoRepo.getHistory(limit: anyNamed('limit')),
Future.delayed(const Duration(milliseconds: 10), () => [persisted]), ).thenAnswer(
(_) => Future.delayed(
const Duration(milliseconds: 10),
() => [persisted],
),
); );
final notifier = container.read(undoServiceProvider.notifier); final notifier = container.read(undoServiceProvider.notifier);
+8 -8
View File
@@ -46,9 +46,8 @@ class ThrowingUrlLauncher extends Mock
Widget _buildScreen({List<Account> accounts = const []}) { Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope( return ProviderScope(
overrides: [ overrides: [
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider
FakeAccountRepository(accounts), .overrideWithValue(FakeAccountRepository(accounts)),
),
], ],
child: const MaterialApp(home: AboutScreen()), child: const MaterialApp(home: AboutScreen()),
); );
@@ -152,10 +151,8 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( () => tester.binding.defaultBinaryMessenger
SystemChannels.platform, .setMockMethodCallHandler(SystemChannels.platform, null),
null,
),
); );
await tester.pumpWidget(_buildScreen()); await tester.pumpWidget(_buildScreen());
@@ -176,7 +173,10 @@ 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(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)')); expect(
clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'),
);
}); });
testWidgets('AboutScreen create-issue button opens Codeberg URL', ( testWidgets('AboutScreen create-issue button opens Codeberg URL', (
+8 -2
View File
@@ -74,7 +74,10 @@ void main() {
recipientKeyId: material.keyId, recipientKeyId: material.keyId,
recipientPublicKeyBytes: material.publicKeyBytes, recipientPublicKeyBytes: material.publicKeyBytes,
accounts: [ accounts: [
AccountPayload(accountJson: account.toJson(), password: 'secret'), AccountPayload(
accountJson: account.toJson(),
password: 'secret',
),
], ],
); );
@@ -96,7 +99,10 @@ void main() {
await tester.tap(find.text('Import')); await tester.tap(find.text('Import'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Imported 1 account successfully.'), findsOneWidget); expect(
find.text('Imported 1 account successfully.'),
findsOneWidget,
);
}, },
); );
+42 -41
View File
@@ -227,53 +227,54 @@ void main() {
expect(find.textContaining('Healthy'), findsOneWidget); expect(find.textContaining('Healthy'), findsOneWidget);
}); });
testWidgets('shows discrepancy details when sync health has discrepancies', testWidgets(
( 'shows discrepancy details when sync health has discrepancies',
tester, (tester) async {
) async { const summary =
const summary = '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; await tester.pumpWidget(
await tester.pumpWidget( buildApp(
buildApp( initialLocation: '/accounts',
initialLocation: '/accounts', overrides: baseOverrides(
overrides: baseOverrides( accounts: [kTestAccount],
accounts: [kTestAccount], syncHealth: SyncHealthRow(
syncHealth: SyncHealthRow( accountId: kTestAccount.id,
accountId: kTestAccount.id, lastVerifiedAt: DateTime(2024, 6),
lastVerifiedAt: DateTime(2024, 6), isHealthy: false,
isHealthy: false, discrepancySummary: summary,
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', ( testWidgets(
tester, 'sync health row is positioned below the account name row',
) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts', initialLocation: '/accounts',
overrides: baseOverrides( overrides: baseOverrides(
accounts: [kTestAccount], accounts: [kTestAccount],
syncHealth: SyncHealthRow( syncHealth: SyncHealthRow(
accountId: kTestAccount.id, accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6), lastVerifiedAt: DateTime(2024, 6),
isHealthy: true, isHealthy: true,
),
), ),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy; final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy; final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos)); expect(healthPos, greaterThan(namePos));
}); },
);
}); });
} }
+66 -68
View File
@@ -96,10 +96,8 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( () => tester.binding.defaultBinaryMessenger
SystemChannels.platform, .setMockMethodCallHandler(SystemChannels.platform, null),
null,
),
); );
const exception = 'TestException: clipboard test'; const exception = 'TestException: clipboard test';
@@ -128,77 +126,79 @@ void main() {
}, },
); );
testWidgets('CrashScreen shows git hash as clickable link above stacktrace', ( testWidgets(
tester, 'CrashScreen shows git hash as clickable link above stacktrace',
) async { (tester) 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('CrashScreen shows version, build mode, and platform in the UI', ( testWidgets(
tester, 'CrashScreen shows version, build mode, and platform in the UI',
) async { (tester) 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,10 +264,8 @@ void main() {
}, },
); );
addTearDown( addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( () => tester.binding.defaultBinaryMessenger
SystemChannels.platform, .setMockMethodCallHandler(SystemChannels.platform, null),
null,
),
); );
const exception = 'TestException: version link clipboard test'; const exception = 'TestException: version link clipboard test';
+49 -50
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.view.physicalSize = const Size(800, 1400); tester,
tester.view.devicePixelRatio = 1.0; ) async {
addTearDown(tester.view.resetPhysicalSize); tester.view.physicalSize = const Size(800, 1400);
addTearDown(tester.view.resetDevicePixelRatio); tester.view.devicePixelRatio = 1.0;
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,9 +182,8 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final button = tester.widget<FilledButton>( final button = tester
find.widgetWithText(FilledButton, 'Save'), .widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
);
expect(button.onPressed, isNull); expect(button.onPressed, isNull);
}); });
+118 -107
View File
@@ -52,7 +52,10 @@ List<Override> _overrides({required EmailBody body, Email? email}) => [
), ),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()), mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body), FakeEmailRepository(
emailDetail: email ?? testEmail(),
emailBody: body,
),
), ),
]; ];
@@ -188,45 +191,45 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'), find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply all',
),
findsNothing, findsNothing,
); );
}); });
testWidgets( testWidgets('Reply on single-recipient email navigates directly to compose',
'Reply on single-recipient email navigates directly to compose', (tester) async {
(tester) async { // testEmail has from=[bob], to=[alice]. After removing alice (own),
// testEmail has from=[bob], to=[alice]. After removing alice (own), // only bob remains → no dialog, navigate straight to compose.
// only bob remains → no dialog, navigate straight to compose. final email = testEmail();
final email = testEmail(); await tester.pumpWidget(
await tester.pumpWidget( buildApp(
buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
initialLocation: overrides: [
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', ..._overrides(
overrides: [ body: const EmailBody(emailId: 'acc-1:42', attachments: []),
..._overrides( email: email,
body: const EmailBody(emailId: 'acc-1:42', attachments: []), ),
email: email, draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
), ],
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), ),
], );
), await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'), find.byWidgetPredicate(
); (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, (tester) async {
) 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',
@@ -255,7 +258,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'), find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -266,9 +271,8 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1)); expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
}); });
testWidgets('Mark as spam is in popup menu, not a standalone button', ( testWidgets('Mark as spam is in popup menu, not a standalone button',
tester, (tester) async {
) 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',
@@ -294,9 +298,8 @@ void main() {
expect(find.text('Mark as spam'), findsOneWidget); 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, (tester) async {
) 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(
@@ -331,7 +334,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'), find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget, findsOneWidget,
); );
}); });
@@ -350,16 +355,17 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'), find.byWidgetPredicate(
(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, (tester) async {
) 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',
@@ -395,16 +401,13 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider.overrideWithValue( mailboxRepositoryProvider
FakeMailboxRepository(), .overrideWithValue(FakeMailboxRepository()),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
emailDetail: testEmail(), emailDetail: testEmail(),
emailBody: const EmailBody( emailBody:
emailId: 'acc-1:42', const EmailBody(emailId: 'acc-1:42', attachments: []),
attachments: [],
),
rawRfc822: rawContent, rawRfc822: rawContent,
), ),
), ),
@@ -433,16 +436,13 @@ void main() {
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]), FakeAccountRepository([kTestAccount]),
), ),
mailboxRepositoryProvider.overrideWithValue( mailboxRepositoryProvider
FakeMailboxRepository(), .overrideWithValue(FakeMailboxRepository()),
),
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository( FakeEmailRepository(
emailDetail: testEmail(), emailDetail: testEmail(),
emailBody: const EmailBody( emailBody:
emailId: 'acc-1:42', const EmailBody(emailId: 'acc-1:42', attachments: []),
attachments: [],
),
rawRfc822: 'Subject: test\r\n\r\nBody', rawRfc822: 'Subject: test\r\n\r\nBody',
), ),
), ),
@@ -483,37 +483,43 @@ void main() {
expect(find.text('Share'), findsOneWidget); expect(find.text('Share'), findsOneWidget);
}); });
testWidgets('long-press on unsubscribe chip shows URL tooltip', ( testWidgets(
tester, 'long-press on unsubscribe chip shows URL tooltip',
) async { (tester) 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: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation:
overrides: _overrides( '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
body: const EmailBody(emailId: 'acc-1:42', attachments: []), overrides: _overrides(
email: email, body: const EmailBody(emailId: 'acc-1:42', attachments: []),
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 is Tooltip && w.message == 'https://example.com/unsubscribe', (w) =>
), 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(find.text('https://example.com/unsubscribe'), findsOneWidget); expect(
}); 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,
@@ -557,31 +563,36 @@ void main() {
expect(find.textContaining('application/pdf'), findsOneWidget); expect(find.textContaining('application/pdf'), findsOneWidget);
}); });
testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', ( testWidgets(
tester, 'Show Mail Structure shows snackbar when mimeTree is absent',
) async { (tester) 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: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation:
overrides: _overrides(body: body), '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
), 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(find.textContaining('Structure not available'), findsOneWidget); expect(
}); find.textContaining('Structure not available'),
findsOneWidget,
);
},
);
}); });
} }
@@ -51,7 +51,9 @@ List<Override> _overrides({
searchHistoryRepositoryProvider.overrideWithValue( searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(), FakeSearchHistoryRepository(),
), ),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)), syncLastErrorProvider.overrideWith(
(ref, _) => Stream.value(syncError),
),
]; ];
void main() { void main() {
@@ -120,7 +122,9 @@ void main() {
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides( overrides: _overrides(
searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')], searchResults: [
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
), ),
), ),
); );
+47 -46
View File
@@ -430,62 +430,63 @@ void main() {
expect(find.text('Result email'), findsWidgets); expect(find.text('Result email'), findsWidgets);
}); });
testWidgets('deleting all search results pops back to previous screen', ( testWidgets(
tester, 'deleting all search results pops back to previous screen',
) async { (tester) 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',
+1 -20
View File
@@ -627,13 +627,11 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
this.menuPosition = MenuPosition.bottom, this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom, this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage, this.afterMailViewAction = AfterMailViewAction.nextMessage,
List<String>? trustedImageSenders, });
}) : _trustedImageSenders = trustedImageSenders ?? [];
MenuPosition menuPosition; MenuPosition menuPosition;
MenuPosition mailViewButtonPosition; MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction; AfterMailViewAction afterMailViewAction;
final List<String> _trustedImageSenders;
@override @override
Stream<UserPreferences> observePreferences() => Stream.value( Stream<UserPreferences> observePreferences() => Stream.value(
@@ -658,23 +656,6 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async { Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action; afterMailViewAction = action;
} }
@override
Stream<List<String>> observeTrustedImageSenders() =>
Stream.value(List.of(_trustedImageSenders));
@override
Future<void> addTrustedImageSender(String senderEmail) async {
final normalized = senderEmail.toLowerCase();
if (!_trustedImageSenders.contains(normalized)) {
_trustedImageSenders.add(normalized);
}
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
_trustedImageSenders.remove(senderEmail.toLowerCase());
}
} }
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
+6 -2
View File
@@ -89,7 +89,9 @@ void main() {
expect(find.text('No results'), findsOneWidget); expect(find.text('No results'), findsOneWidget);
}); });
testWidgets('shows email results under "Messages" section', (tester) async { testWidgets('shows email results under "Messages" section', (
tester,
) async {
final email = testEmail(subject: 'Invoice Q3'); final email = testEmail(subject: 'Invoice Q3');
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
@@ -120,7 +122,9 @@ void main() {
expect(find.text('Invoice Q3'), findsOneWidget); expect(find.text('Invoice Q3'), findsOneWidget);
}); });
testWidgets('shows folder results under "Folders" section', (tester) async { testWidgets('shows folder results under "Folders" section', (
tester,
) async {
const archiveMailbox = Mailbox( const archiveMailbox = Mailbox(
id: 'acc-1:Archive', id: 'acc-1:Archive',
accountId: 'acc-1', accountId: 'acc-1',
+19 -15
View File
@@ -20,12 +20,10 @@ Widget _wrap(Widget child) => MaterialApp(
void main() { void main() {
group('buildEmailHtml', () { group('buildEmailHtml', () {
test( test('forces light color-scheme to prevent black-on-black in dark mode',
'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>');
@@ -46,9 +44,8 @@ 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 = buildEmailHtml( final html =
'<table width="600"><tr><td>x</td></tr></table>', buildEmailHtml('<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.
@@ -65,7 +62,11 @@ 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(const SecureEmailWebView(htmlBody: '<p>Hello <b>world</b></p>')), _wrap(
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);
@@ -91,11 +92,12 @@ 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, (tester) async {
) async {
await tester.pumpWidget( await tester.pumpWidget(
_wrap(const SecureEmailWebView(htmlBody: '<p>Body</p>')), _wrap(
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
),
); );
await tester.pumpWidget( await tester.pumpWidget(
_wrap( _wrap(
@@ -109,7 +111,9 @@ void main() {
}); });
testWidgets('handles empty HTML body', (tester) async { testWidgets('handles empty HTML body', (tester) async {
await tester.pumpWidget(_wrap(const SecureEmailWebView(htmlBody: ''))); await tester.pumpWidget(
_wrap(const SecureEmailWebView(htmlBody: '')),
);
expect(find.byType(SelectableText), findsOneWidget); expect(find.byType(SelectableText), findsOneWidget);
}); });
}); });
+6 -2
View File
@@ -27,9 +27,13 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
sieveRepositoryProvider.overrideWith((ref) => _FakeSieveRepository()), sieveRepositoryProvider.overrideWith(
(ref) => _FakeSieveRepository(),
),
], ],
child: const MaterialApp(home: SieveScriptsScreen(accountId: 'acc-1')), child: const MaterialApp(
home: SieveScriptsScreen(accountId: 'acc-1'),
),
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+16 -21
View File
@@ -38,9 +38,8 @@ void main() {
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
timestamp: DateTime.now().subtract(const Duration(hours: 1)), timestamp: DateTime.now().subtract(const Duration(hours: 1)),
); );
when( when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
mockUndoRepo.getHistory(limit: anyNamed('limit')), .thenAnswer((_) async => [staleAction]);
).thenAnswer((_) async => [staleAction]);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -49,12 +48,10 @@ void main() {
}, },
); );
testWidgets('shows snackbar for fresh action pushed in current session', ( testWidgets('shows snackbar for fresh action pushed in current session',
tester, (tester) async {
) async { when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
when( .thenAnswer((_) async => []);
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -67,20 +64,18 @@ void main() {
emailIds: ['e1'], emailIds: ['e1'],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
); );
await ProviderScope.containerOf( await ProviderScope.containerOf(context)
context, .read(undoServiceProvider.notifier)
).read(undoServiceProvider.notifier).pushAction(freshAction); .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, (tester) async {
) async { when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
when( .thenAnswer((_) async => []);
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => []);
await tester.pumpWidget(buildShell(mockUndoRepo)); await tester.pumpWidget(buildShell(mockUndoRepo));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -93,9 +88,9 @@ void main() {
emailIds: ['e1', 'e2'], emailIds: ['e1', 'e2'],
sourceMailboxPath: 'INBOX', sourceMailboxPath: 'INBOX',
); );
await ProviderScope.containerOf( await ProviderScope.containerOf(context)
context, .read(undoServiceProvider.notifier)
).read(undoServiceProvider.notifier).pushAction(deleteAction); .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);
+31 -29
View File
@@ -35,7 +35,10 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Single mail view button position'), findsOneWidget); expect(
find.text('Single mail view button position'),
findsOneWidget,
);
}); });
testWidgets('menu position bottom option is selected by default', ( testWidgets('menu position bottom option is selected by default', (
@@ -50,9 +53,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>); final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup = tester.widget<RadioGroup<MenuPosition>>( final menuGroup =
radioGroups.first, tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
);
expect(menuGroup.groupValue, MenuPosition.bottom); expect(menuGroup.groupValue, MenuPosition.bottom);
}); });
@@ -68,9 +70,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>); final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup = tester.widget<RadioGroup<MenuPosition>>( final mailViewGroup =
radioGroups.last, tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
);
expect(mailViewGroup.groupValue, MenuPosition.bottom); expect(mailViewGroup.groupValue, MenuPosition.bottom);
}); });
@@ -97,27 +98,27 @@ void main() {
}); });
testWidgets( testWidgets(
'tapping Top in mail view button position section updates the repo', 'tapping Top in mail view button position section updates the repo', (
(tester) async { tester,
await tester.pumpWidget( ) async {
buildApp( await tester.pumpWidget(
initialLocation: '/accounts/preferences', buildApp(
overrides: baseOverrides(), initialLocation: '/accounts/preferences',
), overrides: baseOverrides(),
); ),
await tester.pumpAndSettle(); );
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last); await tester.tap(find.text('Top').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = ProviderScope.containerOf( final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)), tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider) ).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository; as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top); expect(repo.mailViewButtonPosition, MenuPosition.top);
}, });
);
testWidgets('shows after mail action section', (tester) async { testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -152,13 +153,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>); final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group = tester.widget<RadioGroup<AfterMailViewAction>>( final group =
radioGroups.first, tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
);
expect(group.groupValue, AfterMailViewAction.nextMessage); expect(group.groupValue, AfterMailViewAction.nextMessage);
}); });
testWidgets('tapping Return to mailbox updates the repo', (tester) async { testWidgets('tapping Return to mailbox updates the repo', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/preferences', initialLocation: '/accounts/preferences',