Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4515f24268 | ||
|
|
5145d91597 | ||
|
|
de3b99a0d6 |
+2
-6
@@ -4,18 +4,14 @@
|
||||
# In systemd service:
|
||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||
|
||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||
|
||||
# Infrastructure tools required by CI workflows
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
jq \
|
||||
stunnel4 \
|
||||
netcat-openbsd \
|
||||
&& 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
|
||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||
|
||||
+148
-3
@@ -1,14 +1,159 @@
|
||||
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:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- 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:
|
||||
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
|
||||
|
||||
- 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
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
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
|
||||
|
||||
+104
-81
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
@@ -34,78 +34,40 @@ jobs:
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Find the most recent workflow run where deploy-playstore actually succeeded
|
||||
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
|
||||
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
|
||||
# every run and the fallback diff to only cover HEAD~1..HEAD.
|
||||
# Skip if this exact commit was already successfully deployed (prevents
|
||||
# hourly schedule from redeploying the same commit on every tick).
|
||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||
import json, os, sys, urllib.request
|
||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
|
||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("status") == "success"
|
||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||
]
|
||||
# Walk runs newest-first; pick the first one where deploy-playstore
|
||||
# actually ran (conclusion=success), not just skipped.
|
||||
for run in runs:
|
||||
run_id = run.get("id")
|
||||
jobs_url = f"{base_api}/runs/{run_id}/jobs"
|
||||
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(jobs_req) as jr:
|
||||
jobs_data = json.loads(jr.read())
|
||||
for job in jobs_data.get("workflow_jobs", []):
|
||||
if "Deploy to Play Store" in job.get("name", "") and (
|
||||
job.get("conclusion") == "success" or
|
||||
job.get("status") == "success"
|
||||
):
|
||||
print(run.get("head_sha") or "")
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass # skip this run if jobs API fails
|
||||
print("")
|
||||
print(runs[0].get("commit_sha") or "")
|
||||
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("")
|
||||
PYEOF
|
||||
)
|
||||
|
||||
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
|
||||
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"
|
||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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
|
||||
# SHA is not in local history (shallow clone or very old deploy).
|
||||
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
else
|
||||
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
@@ -113,25 +75,13 @@ jobs:
|
||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||
|
||||
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||
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
|
||||
echo "$CHANGED" | grep -qE "$android_re" \
|
||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||
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
|
||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
@@ -149,23 +99,28 @@ jobs:
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Publish Android to Play Store
|
||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||
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"
|
||||
run: task publish-android
|
||||
|
||||
- name: Verify Play Store deployment
|
||||
run: |
|
||||
python3 -m venv /tmp/playstore-venv
|
||||
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
|
||||
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-apk:
|
||||
name: Build & Deploy APK to Server
|
||||
@@ -183,17 +138,31 @@ jobs:
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
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:
|
||||
name: Build Linux Release
|
||||
@@ -211,17 +180,71 @@ jobs:
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task deploy-linux
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
|
||||
@@ -58,18 +58,28 @@ jobs:
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||
env:
|
||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
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
|
||||
if: failure()
|
||||
env:
|
||||
|
||||
@@ -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
|
||||
@@ -18,13 +18,22 @@ jobs:
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
||||
run: task renovate
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
name: Update Website
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -26,18 +24,32 @@ jobs:
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Build & Update Website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Verify Website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: scripts/website-verify.sh
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -8,41 +8,46 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||
- **State/Ready** — Issue is approved and ready for implementation
|
||||
- **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
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/code → loop/code-in-progress → loop/code-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||
any state → State/Question (automated or manual: when blocked)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||
|
||||
**Typical lifecycle for a new feature:**
|
||||
List open issues ready to pick up:
|
||||
|
||||
```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
|
||||
3. Review plan, request changes or approve
|
||||
4. Add label loop/code → agent implements + opens PR
|
||||
5. Review PR, merge
|
||||
6. Close issue
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start implementation on an issue without `State/Ready`
|
||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||
- 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
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
|
||||
# Replace 1003 with the actual UID of dagger-svc
|
||||
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||
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
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -188,5 +188,3 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
|
||||
## Daily Workflow
|
||||
|
||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||
|
||||
<!-- agentloop code test passed -->
|
||||
|
||||
@@ -216,8 +216,3 @@ test/
|
||||
- **Settings** — list and remove accounts
|
||||
- **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
|
||||
# CI Trigger
|
||||
# CI Trigger 2
|
||||
# Dummy commit to verify CI fixes
|
||||
# Dummy commit 3
|
||||
# CI Trigger 1780415300
|
||||
|
||||
+4
-13
@@ -271,7 +271,7 @@ tasks:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
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:
|
||||
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
|
||||
run_dagger "$@" && return 0
|
||||
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|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||
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
|
||||
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
|
||||
sleep 90
|
||||
else
|
||||
@@ -319,16 +319,7 @@ tasks:
|
||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
until [ -s "$PORTFILE" ]; do
|
||||
sleep 0.05
|
||||
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
||||
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
||||
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||
RC=$?
|
||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||
exit $RC
|
||||
fi
|
||||
done
|
||||
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||
PORT=$(cat "$PORTFILE")
|
||||
retry_dagger env \
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||
|
||||
@@ -16,10 +16,8 @@ android {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -7,8 +7,8 @@ require (
|
||||
github.com/Khan/genqlient v0.8.1
|
||||
github.com/dagger/otel-go v1.43.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.33
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -21,25 +21,33 @@ require (
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/sosodev/duration v1.4.0 // 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/otlploghttp v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.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/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.3 // 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
|
||||
|
||||
@@ -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/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
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=
|
||||
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-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/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/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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+13
-36
@@ -181,7 +181,7 @@ func New(
|
||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||
func (m *Ci) toolchain() *dagger.Container {
|
||||
return dag.Container().
|
||||
From("ghcr.io/cirruslabs/flutter:3.44.0").
|
||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||
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{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||
@@ -338,12 +338,7 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
|
||||
return dag.Container().
|
||||
From("alpine:3.21").
|
||||
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
|
||||
// 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/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
// Run cheap structural checks in parallel for faster fail detection.
|
||||
var fastEg errgroup.Group
|
||||
fastEg.Go(func() error {
|
||||
_, err := m.CheckHygiene(ctx)
|
||||
return err
|
||||
})
|
||||
fastEg.Go(func() error {
|
||||
_, err := m.CheckLayers(ctx)
|
||||
return err
|
||||
})
|
||||
if err := fastEg.Wait(); err != nil {
|
||||
return "", err
|
||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||
return "Hygiene check failed", err
|
||||
}
|
||||
if _, err := m.CheckLayers(ctx); err != nil {
|
||||
return "Layer check failed", err
|
||||
}
|
||||
|
||||
checkSetup := m.setup(m.checkSrc())
|
||||
@@ -520,19 +508,16 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
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 eg errgroup.Group
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testBackend, e = m.TestBackend(ctx)
|
||||
testBackend, e = m.TestBackend(egCtx)
|
||||
return e
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testIntegration, e = m.TestIntegration(ctx)
|
||||
testIntegration, e = m.TestIntegration(egCtx)
|
||||
return e
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
@@ -574,8 +559,6 @@ func (m *Ci) BuildWebsite(
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.Directory {
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
@@ -583,13 +566,9 @@ func (m *Ci) BuildWebsite(
|
||||
Include: []string{"website/"},
|
||||
}).WithDirectory("website/content/builds", buildHistory)
|
||||
|
||||
hugo := m.Hugo().
|
||||
return m.Hugo().
|
||||
WithDirectory("/src", websiteSource).
|
||||
WithWorkdir("/src/website")
|
||||
if commitHash != "" {
|
||||
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
|
||||
}
|
||||
return hugo.
|
||||
WithWorkdir("/src/website").
|
||||
WithExec([]string{"hugo", "--minify"}).
|
||||
Directory("public")
|
||||
}
|
||||
@@ -601,10 +580,8 @@ func (m *Ci) PublishWebsite(
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
// +optional
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
|
||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
WithDirectory("/public", public).
|
||||
|
||||
@@ -4,18 +4,6 @@ This file contains tasks which got implemented.
|
||||
|
||||
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 (v34–v36: `user_preferences` table) — in main
|
||||
- PR #307 and issue #299 closed.
|
||||
- Issue #315 closed.
|
||||
|
||||
## Tasks (2026-05-26)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 37;
|
||||
const int dbSchemaVersion = 36;
|
||||
|
||||
@@ -15,10 +15,6 @@ abstract class EmailRepository {
|
||||
int limit = 50,
|
||||
});
|
||||
|
||||
/// Returns threads from the INBOX of every account, sorted by latest date.
|
||||
/// Inbox mailboxes are matched by role = 'inbox'.
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
|
||||
|
||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
|
||||
@@ -5,8 +5,4 @@ abstract class UserPreferencesRepository {
|
||||
Future<void> updateMenuPosition(MenuPosition position);
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||
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;
|
||||
try {
|
||||
final data = Uint8List.fromList(
|
||||
base64.decode(s.substring(_pubKeyPrefix.length)),
|
||||
);
|
||||
final data =
|
||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||
return (
|
||||
keyId: data.sublist(0, _keyIdLen),
|
||||
|
||||
@@ -108,9 +108,8 @@ class SieveInterpreter {
|
||||
}
|
||||
|
||||
bool _globMatch(String value, String pattern) {
|
||||
final regexStr = RegExp.escape(
|
||||
pattern,
|
||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
final regexStr =
|
||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$').hasMatch(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -466,7 +466,9 @@ class _Scanner {
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw SieveParseException('Expected tagged argument at position $_pos');
|
||||
throw SieveParseException(
|
||||
'Expected tagged argument at position $_pos',
|
||||
);
|
||||
}
|
||||
|
||||
String? peekSizeUnit() {
|
||||
@@ -478,7 +480,9 @@ class _Scanner {
|
||||
|
||||
String readDigits() {
|
||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||
throw SieveParseException('Expected number at position $_pos');
|
||||
throw SieveParseException(
|
||||
'Expected number at position $_pos',
|
||||
);
|
||||
}
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||
@@ -489,7 +493,9 @@ class _Scanner {
|
||||
|
||||
String readQuotedString() {
|
||||
if (_src[_pos] != '"') {
|
||||
throw SieveParseException('Expected " at position $_pos');
|
||||
throw SieveParseException(
|
||||
'Expected " at position $_pos',
|
||||
);
|
||||
}
|
||||
_pos++; // skip opening quote
|
||||
final buf = StringBuffer();
|
||||
|
||||
@@ -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.toLowerCase()}"', 'src="$dataUri"')
|
||||
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
||||
.replaceAll(
|
||||
"src='cid:${bareCid.toLowerCase()}'",
|
||||
"src='$dataUri'",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -307,17 +307,6 @@ class LocalSieveApplied extends Table {
|
||||
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).
|
||||
@DataClassName('UserPreferencesRow')
|
||||
class UserPreferences extends Table {
|
||||
@@ -356,7 +345,6 @@ class UserPreferences extends Table {
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
UserPreferences,
|
||||
ImageTrustedSenders,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -623,9 +611,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
userPreferences.afterMailViewAction,
|
||||
);
|
||||
}
|
||||
if (from < 37) {
|
||||
await m.createTable(imageTrustedSenders);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ class LocalSieveRepository {
|
||||
final AppDatabase _db;
|
||||
|
||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||
final rows = await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
final rows = await (_db.select(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
return rows
|
||||
.map(
|
||||
@@ -27,9 +26,10 @@ class LocalSieveRepository {
|
||||
|
||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||
final rowId = int.parse(blobId);
|
||||
final row = await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||
final row = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw Exception('Local script not found: $blobId');
|
||||
return row.content;
|
||||
@@ -44,7 +44,9 @@ class LocalSieveRepository {
|
||||
if (id != null) {
|
||||
final rowId = int.parse(id);
|
||||
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(
|
||||
LocalSieveScriptsCompanion(
|
||||
name: Value(name),
|
||||
@@ -76,9 +78,10 @@ class LocalSieveRepository {
|
||||
|
||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||
final rowId = int.parse(scriptId);
|
||||
await (_db.delete(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
@@ -89,7 +92,9 @@ class LocalSieveRepository {
|
||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||
final rowId = int.parse(scriptId);
|
||||
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)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
|
||||
class DraftRepositoryImpl implements DraftRepository {
|
||||
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
||||
: _imapConnect = imapConnect;
|
||||
DraftRepositoryImpl(
|
||||
this._db,
|
||||
this._accounts, {
|
||||
ImapConnectFn? imapConnect,
|
||||
}) : _imapConnect = imapConnect;
|
||||
|
||||
final AppDatabase _db;
|
||||
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.
|
||||
try {
|
||||
await client.createMailbox('Drafts');
|
||||
@@ -156,9 +162,8 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
? uidList.first.toString()
|
||||
: null;
|
||||
if (uid != null) {
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
||||
DraftsCompanion(imapServerId: Value(uid)),
|
||||
);
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,26 +95,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
|
||||
final query = _db.select(_db.threads).join([
|
||||
innerJoin(
|
||||
_db.mailboxes,
|
||||
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
|
||||
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
|
||||
),
|
||||
]);
|
||||
query
|
||||
..where(_db.mailboxes.role.equals('inbox'))
|
||||
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
|
||||
..limit(limit);
|
||||
return query.watch().map(
|
||||
(rows) => rows
|
||||
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||
List<model.EmailAddress> parseAddresses(String json) {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
@@ -176,7 +156,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
if (threadEmails.isEmpty) return;
|
||||
final latest = threadEmails.last;
|
||||
|
||||
// Collect unique participants across the whole thread.
|
||||
@@ -258,12 +237,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
final msg = fetch.messages.first;
|
||||
final textBody = msg.decodeTextPlainPart();
|
||||
final rawHtml = msg.decodeTextHtmlPart();
|
||||
final htmlBody =
|
||||
@@ -351,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
],
|
||||
'fetchHTMLBodyValues': true,
|
||||
'fetchTextBodyValues': true,
|
||||
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
||||
'bodyProperties': [
|
||||
'partId',
|
||||
'type',
|
||||
'name',
|
||||
'size',
|
||||
'subParts',
|
||||
],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
@@ -1969,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingleOrNull();
|
||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||
|
||||
final alreadyApplied = await (_db.select(
|
||||
_db.localSieveApplied,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||
|
||||
@@ -2071,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (destMailbox == null) {
|
||||
log(
|
||||
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
||||
);
|
||||
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||
return;
|
||||
}
|
||||
destPath = destMailbox.path;
|
||||
@@ -2831,13 +2808,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
final fetch = await client.uidFetchMessage(
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
final msg = fetch.messages.first;
|
||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||
final bytes = part.decodeContentBinary();
|
||||
if (bytes == null) {
|
||||
@@ -2899,14 +2874,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
return msg.renderMessage();
|
||||
final fetch = await client.uidFetchMessage(
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
return fetch.messages.first.renderMessage();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
@@ -2983,20 +2955,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}) async {
|
||||
if (query.length < 2) return [];
|
||||
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)
|
||||
..where((t) {
|
||||
Expression<bool> cond = const Constant(true);
|
||||
@@ -3011,22 +2969,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
..limit(100))
|
||||
.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 results = <model.EmailAddress>[];
|
||||
final lowerQuery = query.toLowerCase();
|
||||
for (final row in sortedRows) {
|
||||
final isSent = sentPaths.contains(row.mailboxPath);
|
||||
final fields = isSent
|
||||
? [row.toAddresses, row.ccJson, row.fromJson]
|
||||
: [row.fromJson, row.toAddresses, row.ccJson];
|
||||
for (final jsonStr in fields) {
|
||||
for (final row in rows) {
|
||||
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
@@ -3305,17 +3252,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(
|
||||
_db.emails,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.emails)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.pendingChanges)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.syncStates,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.syncStates)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -82,9 +82,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
|
||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||
// folders the server doesn't tag with a special-use attribute.
|
||||
final existingRows = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.accountId.equals(account.id)))
|
||||
final existingRows = await (_db.select(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(account.id)))
|
||||
.get();
|
||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||
|
||||
@@ -321,9 +320,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {
|
||||
await (_db.delete(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
}
|
||||
|
||||
@@ -369,9 +367,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
final row = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals(id)))
|
||||
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
|
||||
.getSingle();
|
||||
return _toModel(row);
|
||||
}
|
||||
@@ -423,9 +419,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
final row = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals(dbId)))
|
||||
final row = await (_db.select(_db.mailboxes)
|
||||
..where((t) => t.id.equals(dbId)))
|
||||
.getSingle();
|
||||
return _toModel(row);
|
||||
}
|
||||
|
||||
@@ -24,9 +24,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
|
||||
await _db.transaction(() async {
|
||||
// Remove existing entry for same query (deduplication).
|
||||
await (_db.delete(
|
||||
_db.searchHistoryEntries,
|
||||
)..where((t) => t.query.equals(trimmed)))
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.query.equals(trimmed)))
|
||||
.go();
|
||||
|
||||
await _db.into(_db.searchHistoryEntries).insert(
|
||||
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
.get();
|
||||
|
||||
if (keepIds.isNotEmpty) {
|
||||
await (_db.delete(
|
||||
_db.searchHistoryEntries,
|
||||
)..where((t) => t.id.isNotIn(keepIds)))
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.id.isNotIn(keepIds)))
|
||||
.go();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
await _pruneExpired();
|
||||
|
||||
final keyIdHex = _hex(keyId);
|
||||
final row = await (_db.select(
|
||||
_db.shareKeys,
|
||||
)..where((t) => t.id.equals(keyIdHex)))
|
||||
final row = await (_db.select(_db.shareKeys)
|
||||
..where((t) => t.id.equals(keyIdHex)))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (row == null) return null;
|
||||
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
}
|
||||
|
||||
Future<void> _pruneExpired() async {
|
||||
await (_db.delete(
|
||||
_db.shareKeys,
|
||||
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
||||
await (_db.delete(_db.shareKeys)
|
||||
..where(
|
||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||
|
||||
@override
|
||||
Stream<pref.UserPreferences> observePreferences() {
|
||||
return (_db.select(
|
||||
_db.userPreferences,
|
||||
)..where((t) => t.id.equals(_rowId)))
|
||||
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
|
||||
.watchSingleOrNull()
|
||||
.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) {
|
||||
if (row == null) return const pref.UserPreferences();
|
||||
return pref.UserPreferences(
|
||||
|
||||
+10
-51
@@ -101,9 +101,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
||||
ref,
|
||||
) {
|
||||
final searchHistoryRepositoryProvider =
|
||||
Provider<SearchHistoryRepository>((ref) {
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
@@ -136,10 +135,8 @@ final syncHealthProvider =
|
||||
.watchSingleOrNull();
|
||||
});
|
||||
|
||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||
ref,
|
||||
accountId,
|
||||
) {
|
||||
final isSyncingProvider =
|
||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||
});
|
||||
|
||||
@@ -188,9 +185,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||
});
|
||||
|
||||
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
||||
UndoService.new,
|
||||
);
|
||||
final undoServiceProvider =
|
||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
@@ -211,38 +207,10 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
repo.getEmailBody(_emailId),
|
||||
]);
|
||||
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);
|
||||
}
|
||||
|
||||
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 allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts();
|
||||
});
|
||||
|
||||
final accountByIdProvider =
|
||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||
@@ -264,21 +232,12 @@ final accountConnectionStatusProvider =
|
||||
.testConnection(account, password);
|
||||
});
|
||||
|
||||
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
||||
ref,
|
||||
) {
|
||||
final userPreferencesRepositoryProvider =
|
||||
Provider<UserPreferencesRepository>((ref) {
|
||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||
ref,
|
||||
) {
|
||||
final userPreferencesProvider =
|
||||
StreamProvider.autoDispose<UserPreferences>((ref) {
|
||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||
});
|
||||
|
||||
final trustedImageSendersProvider =
|
||||
StreamProvider.autoDispose<List<String>>((ref) {
|
||||
return ref
|
||||
.watch(userPreferencesRepositoryProvider)
|
||||
.observeTrustedImageSenders();
|
||||
});
|
||||
|
||||
+1
-6
@@ -4,7 +4,6 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||
@@ -25,15 +24,11 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/inbox',
|
||||
initialLocation: '/accounts',
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => UndoShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/inbox',
|
||||
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/accounts',
|
||||
builder: (ctx, state) => const AccountListScreen(),
|
||||
|
||||
@@ -72,10 +72,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
|
||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
final launched =
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -123,10 +121,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
final launched =
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -180,7 +176,9 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(_launchUrl(context, Uri.parse(href)));
|
||||
unawaited(
|
||||
_launchUrl(context, Uri.parse(href)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -120,76 +120,15 @@ class _AccountTile extends ConsumerWidget {
|
||||
final health = ref.watch(syncHealthProvider(account.id));
|
||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text('${account.email}\n$typeLabel'),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
status.when(
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
data: (_) =>
|
||||
const Icon(Icons.check_circle, color: Colors.green),
|
||||
error: (e, _) => Tooltip(
|
||||
message: e.toString(),
|
||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_AccountAction>(
|
||||
onSelected: (action) => _onAction(context, action),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.forceSync,
|
||||
child: Text('Force full sync'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.send,
|
||||
child: Text('Send accounts'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: health.when(
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${account.email}\n$typeLabel'),
|
||||
const SizedBox(height: 4),
|
||||
health.when(
|
||||
data: (h) {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
@@ -202,7 +141,7 @@ class _AccountTile extends ConsumerWidget {
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
Flexible(
|
||||
child: Text(
|
||||
h.isHealthy
|
||||
? 'Healthy'
|
||||
@@ -216,8 +155,66 @@ class _AccountTile extends ConsumerWidget {
|
||||
loading: () => const Text('Sync health: checking...'),
|
||||
error: (e, _) => Text('Sync health error: $e'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
status.when(
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
||||
error: (e, _) => Tooltip(
|
||||
message: e.toString(),
|
||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_AccountAction>(
|
||||
onSelected: (action) => _onAction(context, action),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.forceSync,
|
||||
child: Text('Force full sync'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.send,
|
||||
child: Text('Send accounts'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
),
|
||||
),
|
||||
_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(
|
||||
child: Padding(
|
||||
|
||||
@@ -158,7 +158,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
for (final account in selected) {
|
||||
final password = await repo.getPassword(account.id);
|
||||
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!)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Encrypted code copied to clipboard'),
|
||||
content: Text(
|
||||
'Encrypted code copied to clipboard',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,9 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString('assets/changelog.txt'),
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
final _formattedDates = <int, String>{};
|
||||
|
||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||
|
||||
String _fmtDate(DateTime dt) =>
|
||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||
|
||||
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||
const CombinedInboxScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CombinedInboxScreen> createState() =>
|
||||
_CombinedInboxScreenState();
|
||||
}
|
||||
|
||||
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accountsAsync = ref.watch(allAccountsProvider);
|
||||
|
||||
return accountsAsync.when(
|
||||
loading: () => const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
body: Center(child: Text('Error: $e')),
|
||||
),
|
||||
data: (accounts) {
|
||||
if (accounts.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) context.go('/accounts');
|
||||
});
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final accountNames = {
|
||||
for (final a in accounts) a.id: a.displayName,
|
||||
};
|
||||
final showAccount = accounts.length > 1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(accounts),
|
||||
drawer: _buildDrawer(context, accounts),
|
||||
body: _buildBody(accountNames, showAccount),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/compose'),
|
||||
child: const Icon(Icons.edit),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||
return AppBar(
|
||||
title: const Text('Combined Inbox'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sync),
|
||||
tooltip: 'Sync all',
|
||||
onPressed: () {
|
||||
for (final a in accounts) {
|
||||
ref.read(syncManagerProvider).syncNow(a.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const DrawerHeader(
|
||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||
child: Text(
|
||||
'sharedinbox.de',
|
||||
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.manage_accounts),
|
||||
title: const Text('Accounts'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.go('/accounts');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add),
|
||||
title: const Text('Add account'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/add'));
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
for (final account in accounts)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.inbox),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text(account.email),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/${account.id}/mailboxes'));
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text('Preferences'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/preferences'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: const Text('Undo Log'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/undo-log'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('About'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/about'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
|
||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
final accounts = ref.read(allAccountsProvider).value ?? [];
|
||||
for (final a in accounts) {
|
||||
ref.read(syncManagerProvider).syncNow(a.id);
|
||||
}
|
||||
},
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final threads = snap.data!;
|
||||
if (threads.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: Center(child: Text('No emails')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return _buildThreadList(threads, accountNames, showAccount);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThreadList(
|
||||
List<EmailThread> threads,
|
||||
Map<String, String> accountNames,
|
||||
bool showAccount,
|
||||
) {
|
||||
final hasMore = threads.length == _limit;
|
||||
return ListView.builder(
|
||||
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == threads.length) {
|
||||
return TextButton(
|
||||
onPressed: () => setState(() => _limit += _pageSize),
|
||||
child: const Text('Load more'),
|
||||
);
|
||||
}
|
||||
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThreadTile(
|
||||
BuildContext ctx,
|
||||
EmailThread t,
|
||||
Map<String, String> accountNames,
|
||||
bool showAccount,
|
||||
) {
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
|
||||
final tile = ListTile(
|
||||
leading: Icon(
|
||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
senderNames.isEmpty ? '(unknown)' : senderNames,
|
||||
style: t.hasUnread
|
||||
? const TextStyle(fontWeight: FontWeight.bold)
|
||||
: null,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (t.messageCount > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
'[${t.messageCount}]',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.hasUnread
|
||||
? const TextStyle(fontWeight: FontWeight.bold)
|
||||
: null,
|
||||
),
|
||||
if (t.preview != null && t.preview!.isNotEmpty)
|
||||
Text(
|
||||
t.preview!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
if (showAccount)
|
||||
Text(
|
||||
accountNames[t.accountId] ?? t.accountId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (t.isFlagged)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_fmtDate(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: t.messageCount > 1
|
||||
? () => context.push(
|
||||
'/accounts/${t.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
)
|
||||
: () => context.push(
|
||||
'/accounts/${t.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
);
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||
background: _swipeBackground(
|
||||
alignment: Alignment.centerLeft,
|
||||
color: Colors.green,
|
||||
icon: Icons.archive,
|
||||
label: 'Archive',
|
||||
),
|
||||
secondaryBackground: _swipeBackground(
|
||||
alignment: Alignment.centerRight,
|
||||
color: Colors.red,
|
||||
icon: Icons.delete,
|
||||
label: 'Delete',
|
||||
),
|
||||
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSwipeDismissed(
|
||||
EmailThread t,
|
||||
DismissDirection direction,
|
||||
) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
final originalEmails = (await Future.wait(
|
||||
t.emailIds.map((id) => repo.getEmail(id)),
|
||||
))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
final archive = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.findMailboxByRole(t.accountId, 'archive');
|
||||
if (!mounted || archive == null) return;
|
||||
|
||||
for (final id in t.emailIds) {
|
||||
await repo.moveEmail(id, archive.path);
|
||||
}
|
||||
final action = UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: t.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: t.mailboxPath,
|
||||
destinationMailboxPath: archive.path,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
return;
|
||||
}
|
||||
|
||||
String? lastDestPath;
|
||||
for (final id in t.emailIds) {
|
||||
lastDestPath = await repo.deleteEmail(id);
|
||||
}
|
||||
final action = UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: t.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: t.emailIds,
|
||||
sourceMailboxPath: t.mailboxPath,
|
||||
destinationMailboxPath: lastDestPath,
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Widget _swipeBackground({
|
||||
required AlignmentGeometry alignment,
|
||||
required Color color,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
}) {
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: alignment,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(label, style: const TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
await OpenFilex.open(path);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to open file: $e'),
|
||||
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
|
||||
Future<void> _send() async {
|
||||
if (_accountId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Select an account first'),
|
||||
@@ -251,7 +255,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (mounted) context.pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Send failed: $e'),
|
||||
|
||||
@@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget {
|
||||
builder: (context, snapshot) => Text(
|
||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -54,9 +54,8 @@ Future<Mailbox?> resolveMailboxByRole(
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
for (final m in mailboxes.where(
|
||||
(m) => m.path != currentMailboxPath,
|
||||
))
|
||||
for (final m
|
||||
in mailboxes.where((m) => m.path != currentMailboxPath))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(m.name),
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.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/snooze_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -73,7 +72,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_replyWithRecipientDialog(context, header, body));
|
||||
unawaited(
|
||||
_replyWithRecipientDialog(context, header, body),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -111,6 +121,25 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_move_outline),
|
||||
tooltip: 'Move to folder',
|
||||
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFlagged ? Icons.star : Icons.star_border,
|
||||
@@ -125,15 +154,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
@@ -142,18 +166,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
value: '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 {
|
||||
if (value == 'forward' && header != null) {
|
||||
unawaited(_forward(context, header, body));
|
||||
} else if (value == 'move' && header != null) {
|
||||
unawaited(_moveTo(context, header));
|
||||
} else if (value == 'snooze' && header != null) {
|
||||
unawaited(_snooze(context, header));
|
||||
} else if (value == 'spam' && header != null) {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
} else if (value == 'mark_unread') {
|
||||
if (value == 'mark_unread') {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
@@ -171,35 +190,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (d) {
|
||||
final trusted =
|
||||
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||
return _buildBody(context, d.$1, d.$2, trusted);
|
||||
},
|
||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
BuildContext ctx,
|
||||
Email? header,
|
||||
EmailBody body,
|
||||
List<String> trustedSenders,
|
||||
) {
|
||||
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
||||
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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||
if (hasHtml) ...[
|
||||
if (!effectiveLoadImages)
|
||||
if (!_loadRemoteImages)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
@@ -207,40 +210,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 18),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () {
|
||||
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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressed: () => setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: effectiveLoadImages,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -291,9 +267,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.observeThreads(header.accountId, header.mailboxPath)
|
||||
.first;
|
||||
|
||||
final currentIndex = threads.indexWhere(
|
||||
(t) => t.emailIds.contains(widget.emailId),
|
||||
);
|
||||
final currentIndex =
|
||||
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
|
||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||
return threads[currentIndex + 1].latestEmailId;
|
||||
}
|
||||
@@ -548,7 +523,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
||||
extra: {
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': quoted,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -650,9 +628,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.fetchRawRfc822(widget.emailId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -766,7 +744,47 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
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 +795,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
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;
|
||||
@@ -789,13 +809,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog.fullscreen(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Mail Structure'),
|
||||
leading: const CloseButton(),
|
||||
),
|
||||
body: ListView.builder(
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Mail Structure'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final row = rows[i];
|
||||
@@ -824,6 +843,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -881,8 +906,14 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||
SegmentedButton<_Placement>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: _Placement.to, label: Text('To')),
|
||||
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
||||
ButtonSegment(
|
||||
value: _Placement.to,
|
||||
label: Text('To'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.cc,
|
||||
label: Text('Cc'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Placement.skip,
|
||||
label: Text('Skip'),
|
||||
|
||||
@@ -381,7 +381,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
return MaterialBanner(
|
||||
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(
|
||||
Icons.sync_problem,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
@@ -395,8 +399,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
context.push('/accounts/${widget.accountId}/sync-log'),
|
||||
onPressed: () => context.push(
|
||||
'/accounts/${widget.accountId}/sync-log',
|
||||
),
|
||||
child: const Text('View log'),
|
||||
),
|
||||
TextButton(
|
||||
|
||||
@@ -10,9 +10,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
|
||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||
ref,
|
||||
) async {
|
||||
final _searchHistoryProvider =
|
||||
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||
});
|
||||
|
||||
|
||||
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
||||
title: Text(
|
||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
|
||||
@@ -113,14 +113,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
|
||||
@override
|
||||
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(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
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(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
@@ -171,17 +163,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
FutureBuilder<EmailBody>(
|
||||
future: _bodyFuture,
|
||||
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) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
@@ -192,48 +173,21 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
final body = snapshot.data!;
|
||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasHtml) ...[
|
||||
if (!effectiveLoadImages)
|
||||
if (!_loadRemoteImages)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 16),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () {
|
||||
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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: effectiveLoadImages,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
|
||||
@@ -84,7 +84,9 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
|
||||
@@ -12,7 +12,6 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Preferences')),
|
||||
@@ -91,7 +90,9 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
),
|
||||
RadioListTile<MenuPosition>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
@@ -121,56 +122,21 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
children: [
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
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,
|
||||
),
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Return to mailbox'),
|
||||
subtitle: Text('Return to the message list.'),
|
||||
subtitle: Text(
|
||||
'Return to the message list.',
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,9 +26,8 @@ String buildAboutMarkdown({
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final textScale = MediaQuery.of(
|
||||
context,
|
||||
).textScaler.scale(1.0).toStringAsFixed(1);
|
||||
final textScale =
|
||||
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
||||
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -111,16 +111,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
);
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
try {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
} catch (_) {
|
||||
// WebView not ready yet; height stays at default
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +187,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
final launched =
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open: $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ dependencies:
|
||||
flutter_secure_storage: ^10.0.0
|
||||
|
||||
# Date formatting
|
||||
intl: ^0.20.2
|
||||
intl: any
|
||||
|
||||
# File picking (compose attachments) and opening downloaded attachments
|
||||
file_picker: ^12.0.0-beta.4
|
||||
|
||||
+1
-24
@@ -5,35 +5,12 @@
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"enabled": false
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
||||
"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>.*)$"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+1135
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,6 @@ const _excluded = {
|
||||
'lib/ui/screens/about_screen.dart',
|
||||
'lib/ui/screens/email_action_helpers.dart',
|
||||
'lib/ui/utils/about_markdown.dart',
|
||||
'lib/ui/widgets/email_headers_dialog.dart',
|
||||
'lib/ui/widgets/email_tile.dart',
|
||||
'lib/core/sync/account_sync_manager.dart',
|
||||
'lib/core/sync/background_sync.dart',
|
||||
|
||||
@@ -1,79 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${SOPS_AGE_KEY:-}" ]; then
|
||||
echo "Error: SOPS_AGE_KEY must be set."
|
||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Decrypting secrets with SOPS..."
|
||||
export SOPS_AGE_KEY="$SOPS_AGE_KEY"
|
||||
SECRETS_JSON=$(mktemp)
|
||||
trap "rm -f $SECRETS_JSON" EXIT
|
||||
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||
if [ "$host" == "$port" ]; then
|
||||
port="8774"
|
||||
fi
|
||||
|
||||
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
||||
|
||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||
|
||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||
# can use them without referencing Forgejo secrets directly.
|
||||
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"
|
||||
MAX_PROBE_ATTEMPTS=5
|
||||
PROBE_DELAY=30
|
||||
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||
echo "Found active server on $host:$port"
|
||||
break
|
||||
fi
|
||||
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"
|
||||
export_secret "SSH_KNOWN_HOSTS"
|
||||
export_secret "SSH_USER"
|
||||
export_secret "SSH_HOST"
|
||||
export_secret "WEBSITE_SSH_HOST"
|
||||
export_secret "PLAY_STORE_CONFIG_JSON"
|
||||
export_secret "ANDROID_KEYSTORE_BASE64"
|
||||
export_secret "ANDROID_KEYSTORE_PASSWORD"
|
||||
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||
export_secret "RENOVATE_FORGEJO_TOKEN"
|
||||
|
||||
# Setup SSH directory and keys
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
||||
chmod 600 ~/.ssh/dagger_key
|
||||
|
||||
# Add remote host to known_hosts
|
||||
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Create a background SSH tunnel to the Dagger engine.
|
||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||
|
||||
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
|
||||
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||
timeout 8 dagger version >/dev/null 2>&1; then
|
||||
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||
|
||||
# Verify the connection
|
||||
echo "Verifying connection to Dagger engine via SSH tunnel..."
|
||||
# Use a simple command that doesn't require complex GraphQL operations.
|
||||
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
|
||||
echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
|
||||
# Debug
|
||||
ps aux | grep ssh
|
||||
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||
mkdir -p /tmp/dagger-tls
|
||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||
chmod 600 /tmp/dagger-tls/client.key
|
||||
|
||||
# 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
|
||||
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
@@ -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()
|
||||
@@ -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()
|
||||
File diff suppressed because one or more lines are too long
@@ -20,67 +20,63 @@ Future<imap.ImapClient> _fakeImapConnect(
|
||||
throw const SocketException('fake — no real IMAP server in tests');
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
imapConnect: _fakeImapConnect,
|
||||
);
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
imapConnect: _fakeImapConnect,
|
||||
);
|
||||
|
||||
final a1 = _account('1');
|
||||
final a2 = _account('2');
|
||||
final a1 = _account('1');
|
||||
final a2 = _account('2');
|
||||
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
|
||||
// Allow some time for listeners to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// Allow some time for listeners to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
|
||||
manager.dispose();
|
||||
},
|
||||
);
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
|
||||
final a1 = _jmapAccount('1');
|
||||
final a2 = _jmapAccount('2');
|
||||
final a1 = _jmapAccount('1');
|
||||
final a2 = _jmapAccount('2');
|
||||
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
manager.start();
|
||||
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['2'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
|
||||
manager.dispose();
|
||||
},
|
||||
);
|
||||
manager.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Account _account(String id) => Account(
|
||||
@@ -175,7 +171,11 @@ class _FakeEmails implements EmailRepository {
|
||||
final syncCounts = <String, int>{};
|
||||
|
||||
@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([]);
|
||||
|
||||
@override
|
||||
@@ -186,10 +186,6 @@ class _FakeEmails implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
|
||||
@@ -566,61 +566,59 @@ void main() {
|
||||
expect(pending.first.changeType, 'delete');
|
||||
});
|
||||
|
||||
test(
|
||||
'downloadAttachment fetches binary attachment bytes from IMAP',
|
||||
() async {
|
||||
final attachmentBytes = Uint8List.fromList(
|
||||
List.generate(32, (i) => i + 1),
|
||||
test('downloadAttachment fetches binary attachment bytes from IMAP',
|
||||
() async {
|
||||
final attachmentBytes = Uint8List.fromList(
|
||||
List.generate(32, (i) => i + 1),
|
||||
);
|
||||
const attachmentName = 'hello.bin';
|
||||
const attachmentMime = 'application/octet-stream';
|
||||
|
||||
// Build a multipart email with a binary attachment and append it.
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final builder = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||
..text = 'See attachment.';
|
||||
builder.addBinary(
|
||||
attachmentBytes,
|
||||
MediaType.fromText(attachmentMime),
|
||||
filename: attachmentName,
|
||||
);
|
||||
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,
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
try {
|
||||
final builder = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||
..text = 'See attachment.';
|
||||
builder.addBinary(
|
||||
attachmentBytes,
|
||||
MediaType.fromText(attachmentMime),
|
||||
filename: attachmentName,
|
||||
);
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.hasAttachment, isTrue);
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.hasAttachment, isTrue);
|
||||
|
||||
final body = await r.emails.getEmailBody(emails.first.id);
|
||||
expect(body.attachments, hasLength(1));
|
||||
expect(body.attachments.first.filename, attachmentName);
|
||||
expect(body.attachments.first.contentType, attachmentMime);
|
||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||
final body = await r.emails.getEmailBody(emails.first.id);
|
||||
expect(body.attachments, hasLength(1));
|
||||
expect(body.attachments.first.filename, attachmentName);
|
||||
expect(body.attachments.first.contentType, attachmentMime);
|
||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||
|
||||
final path = await r.emails.downloadAttachment(
|
||||
emails.first.id,
|
||||
body.attachments.first,
|
||||
);
|
||||
final downloaded = await File(path).readAsBytes();
|
||||
expect(downloaded, equals(attachmentBytes));
|
||||
},
|
||||
);
|
||||
final path = await r.emails.downloadAttachment(
|
||||
emails.first.id,
|
||||
body.attachments.first,
|
||||
);
|
||||
final downloaded = await File(path).readAsBytes();
|
||||
expect(downloaded, equals(attachmentBytes));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,15 +73,13 @@ abstract class AccountRepositoryContract {
|
||||
expect(await repo.getPassword(_a.id), 'new');
|
||||
});
|
||||
|
||||
test(
|
||||
'removeAccount makes account disappear from observeAccounts',
|
||||
() async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
await repo.removeAccount(_a.id);
|
||||
expect(await repo.observeAccounts().first, isEmpty);
|
||||
},
|
||||
);
|
||||
test('removeAccount makes account disappear from observeAccounts',
|
||||
() async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
await repo.removeAccount(_a.id);
|
||||
expect(await repo.observeAccounts().first, isEmpty);
|
||||
});
|
||||
|
||||
test('getAccount returns null after removeAccount', () async {
|
||||
final repo = makeRepo();
|
||||
|
||||
@@ -37,41 +37,44 @@ void main() {
|
||||
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
||||
// loop must stop permanently instead of retrying indefinitely with backoff.
|
||||
test(
|
||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||
() async {
|
||||
final syncLog = FakeSyncLogRepository();
|
||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||
() async {
|
||||
final syncLog = FakeSyncLogRepository();
|
||||
|
||||
final m = AccountSyncManager(
|
||||
_AccountRepositoryWithMissingPlugin(),
|
||||
FakeMailboxRepositoryWithInbox(),
|
||||
FakeEmailRepository(),
|
||||
syncLog: syncLog,
|
||||
);
|
||||
final m = AccountSyncManager(
|
||||
_AccountRepositoryWithMissingPlugin(),
|
||||
FakeMailboxRepositoryWithInbox(),
|
||||
FakeEmailRepository(),
|
||||
syncLog: syncLog,
|
||||
);
|
||||
|
||||
m.start();
|
||||
m.start();
|
||||
|
||||
// Allow the first sync cycle to run and fail.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// Allow the first sync cycle to run and fail.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
expect(syncLog.logs.first.success, isFalse);
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
expect(syncLog.logs.first.success, isFalse);
|
||||
|
||||
// Kicking the loop should have no effect once it has stopped permanently.
|
||||
m.syncNow('1');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
// Kicking the loop should have no effect once it has stopped permanently.
|
||||
m.syncNow('1');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Before the fix: kick triggers a retry → 2 log entries.
|
||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
// Before the fix: kick triggers a retry → 2 log entries.
|
||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||
expect(syncLog.logs, hasLength(1));
|
||||
|
||||
m.dispose();
|
||||
},
|
||||
);
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class FakeEmailRepository implements EmailRepository {
|
||||
@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([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
@@ -81,9 +84,6 @@ class FakeEmailRepository implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
|
||||
@@ -287,19 +287,6 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({
|
||||
int? limit = 50,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeAllInboxThreads,
|
||||
[],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
||||
String? accountId,
|
||||
|
||||
@@ -9,13 +9,12 @@ void main() {
|
||||
// startup, throwing PlatformException(channel-error, ...).
|
||||
// registerBackgroundSync() must absorb the failure and let the app continue.
|
||||
test(
|
||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native WorkManager plugin is not
|
||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||
// MissingPluginException. The fix catches it. This test fails before the
|
||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||
await expectLater(registerBackgroundSync(), completes);
|
||||
},
|
||||
);
|
||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native WorkManager plugin is not
|
||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||
// MissingPluginException. The fix catches it. This test fails before the
|
||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||
await expectLater(registerBackgroundSync(), completes);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,9 +86,8 @@ void main() {
|
||||
final result = injectInlineImages(html, msg);
|
||||
|
||||
// Extract base64 payload from the data URI.
|
||||
final match = RegExp(
|
||||
r'data:image/png;base64,([A-Za-z0-9+/=]+)',
|
||||
).firstMatch(result);
|
||||
final match =
|
||||
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
|
||||
expect(match, isNotNull);
|
||||
final decoded = base64.decode(match!.group(1)!);
|
||||
expect(decoded.length, greaterThan(0));
|
||||
|
||||
@@ -44,7 +44,10 @@ abstract class EmailRepositoryContract {
|
||||
void run() {
|
||||
test('observeEmails starts empty', () async {
|
||||
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 {
|
||||
@@ -58,7 +61,10 @@ abstract class EmailRepositoryContract {
|
||||
test('observeEmails only returns emails for the given mailbox', () async {
|
||||
final repo = await makeRepo();
|
||||
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 {
|
||||
@@ -110,7 +116,11 @@ abstract class EmailRepositoryContract {
|
||||
|
||||
test('setFlag flagged updates isFlagged', () async {
|
||||
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);
|
||||
final email = await repo.getEmail('er-acc:11');
|
||||
expect(email!.isFlagged, isTrue);
|
||||
@@ -147,7 +157,10 @@ abstract class EmailRepositoryContract {
|
||||
|
||||
test('observeThreads starts empty', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
|
||||
expect(
|
||||
await repo.observeThreads(_account.id, 'INBOX').first,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,103 +453,47 @@ void main() {
|
||||
expect(results.first.subject, 'foobar baz');
|
||||
});
|
||||
|
||||
test(
|
||||
'searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
test('searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
final older = DateTime(2024);
|
||||
final newer = DateTime(2024, 6);
|
||||
final older = DateTime(2024);
|
||||
final newer = DateTime(2024, 6);
|
||||
|
||||
// Two emails — older one has alice@, newer one has bob@.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:old',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
receivedAt: older,
|
||||
toAddresses: const Value(
|
||||
'[{"name":"Alice","email":"alice@example.com"}]',
|
||||
),
|
||||
// Two emails — older one has alice@, newer one has bob@.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:old',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
receivedAt: older,
|
||||
toAddresses: const Value(
|
||||
'[{"name":"Alice","email":"alice@example.com"}]',
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:new',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
receivedAt: newer,
|
||||
toAddresses: const Value(
|
||||
'[{"name":"Bob","email":"bob@example.com"}]',
|
||||
),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:new',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
receivedAt: newer,
|
||||
toAddresses: const Value(
|
||||
'[{"name":"Bob","email":"bob@example.com"}]',
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
|
||||
// Query matching both; newer (bob) should come first.
|
||||
final results = await r.emails.searchAddresses(null, 'example');
|
||||
expect(results.map((a) => a.email).toList(), [
|
||||
'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',
|
||||
]);
|
||||
},
|
||||
);
|
||||
// Query matching both; newer (bob) should come first.
|
||||
final results = await r.emails.searchAddresses(null, 'example');
|
||||
expect(
|
||||
results.map((a) => a.email).toList(),
|
||||
['bob@example.com', 'alice@example.com'],
|
||||
);
|
||||
});
|
||||
|
||||
// ── IMAP method tests ────────────────────────────────────────────────────
|
||||
|
||||
@@ -753,47 +697,47 @@ void main() {
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'snooze flush selects src mailbox and moves email to Snoozed',
|
||||
() async {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Snoozed',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'Email',
|
||||
resourceId: 'acc-1:5',
|
||||
changeType: 'snooze',
|
||||
payload: jsonEncode({
|
||||
'uid': 5,
|
||||
'src': 'INBOX',
|
||||
'dest': 'Snoozed',
|
||||
'until': '2026-05-10T15:00:00.000',
|
||||
}),
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
test('snooze flush selects src mailbox and moves email to Snoozed',
|
||||
() async {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
final r = _makeRepos(
|
||||
imapConnect: (_, __, ___) async => spy,
|
||||
);
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Snoozed',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'Email',
|
||||
resourceId: 'acc-1:5',
|
||||
changeType: 'snooze',
|
||||
payload: jsonEncode({
|
||||
'uid': 5,
|
||||
'src': 'INBOX',
|
||||
'dest': 'Snoozed',
|
||||
'until': '2026-05-10T15:00:00.000',
|
||||
}),
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
await r.emails.flushPendingChanges('acc-1', 'pw');
|
||||
await r.emails.flushPendingChanges('acc-1', 'pw');
|
||||
|
||||
// Change successfully applied — removed from queue.
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
// Source mailbox extracted from 'src', not 'mailboxPath'.
|
||||
expect(spy.selectedMailbox, 'INBOX');
|
||||
expect(spy.createdMailbox, 'Snoozed');
|
||||
expect(spy.movedToMailbox, 'Snoozed');
|
||||
},
|
||||
);
|
||||
// Change successfully applied — removed from queue.
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
// Source mailbox extracted from 'src', not 'mailboxPath'.
|
||||
expect(spy.selectedMailbox, 'INBOX');
|
||||
expect(spy.createdMailbox, 'Snoozed');
|
||||
expect(spy.movedToMailbox, 'Snoozed');
|
||||
});
|
||||
});
|
||||
|
||||
group('Snooze', () {
|
||||
@@ -1696,123 +1640,119 @@ void main() {
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
|
||||
() async {
|
||||
final List<Map<String, dynamic>> capturedBodies = [];
|
||||
final client = MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'apiUrl': 'https://jmap.example.com/api/',
|
||||
'accounts': {
|
||||
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': 'acct1',
|
||||
'urn:ietf:params:jmap:mail': 'acct1',
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'sess1',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
capturedBodies.add(body);
|
||||
final calls = body['methodCalls'] as List;
|
||||
final methodName = (calls.first as List)[0] as String;
|
||||
if (methodName == 'Mailbox/set') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 's1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': {
|
||||
'new-snoozed': {'id': 'mbx-snoozed'},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
|
||||
() async {
|
||||
final List<Map<String, dynamic>> capturedBodies = [];
|
||||
final client = MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'apiUrl': 'https://jmap.example.com/api/',
|
||||
'accounts': {
|
||||
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': 'acct1',
|
||||
'urn:ietf:params:jmap:mail': 'acct1',
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'sess1',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
capturedBodies.add(body);
|
||||
final calls = body['methodCalls'] as List;
|
||||
final methodName = (calls.first as List)[0] as String;
|
||||
if (methodName == 'Mailbox/set') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 's1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/set',
|
||||
{'accountId': 'acct1', 'updated': {}},
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': {
|
||||
'new-snoozed': {'id': 'mbx-snoozed'},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 's1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/set',
|
||||
{'accountId': 'acct1', 'updated': {}},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
}),
|
||||
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.
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||
|
||||
// First API call should be Mailbox/set to create the Snoozed folder.
|
||||
expect(capturedBodies, hasLength(2));
|
||||
final firstCall =
|
||||
((capturedBodies.first['methodCalls'] as List).first as List)[0];
|
||||
expect(firstCall, 'Mailbox/set');
|
||||
// Change successfully applied — removed from queue.
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
|
||||
// Second call should be Email/set using the newly created mailbox ID.
|
||||
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
||||
as List)[1] as Map<String, dynamic>;
|
||||
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||
as Map<String, dynamic>;
|
||||
expect(update['mailboxIds/mbx-snoozed'], true);
|
||||
},
|
||||
);
|
||||
// First API call should be Mailbox/set to create the Snoozed folder.
|
||||
expect(capturedBodies, hasLength(2));
|
||||
final firstCall =
|
||||
((capturedBodies.first['methodCalls'] as List).first as List)[0];
|
||||
expect(firstCall, 'Mailbox/set');
|
||||
|
||||
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',
|
||||
}),
|
||||
);
|
||||
// Second call should be Email/set using the newly created mailbox ID.
|
||||
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
||||
as List)[1] as Map<String, dynamic>;
|
||||
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||
as Map<String, dynamic>;
|
||||
expect(update['mailboxIds/mbx-snoozed'], true);
|
||||
});
|
||||
|
||||
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).
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
},
|
||||
);
|
||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||
|
||||
// Change applied without needing Mailbox/set (dest was already a valid ID).
|
||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('JMAP syncEmails body caching', () {
|
||||
@@ -2342,42 +2282,41 @@ void main() {
|
||||
|
||||
group('concurrent moves', () {
|
||||
test(
|
||||
'two simultaneous moves enqueue two changes and leave email in last destination',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
'two simultaneous moves enqueue two changes and leave email in last destination',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 5,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
// Fire both moves without awaiting to exercise concurrent enqueue logic.
|
||||
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
|
||||
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
|
||||
await Future.wait([f1, f2]);
|
||||
// Fire both moves without awaiting to exercise concurrent enqueue logic.
|
||||
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
|
||||
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
|
||||
await Future.wait([f1, f2]);
|
||||
|
||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||
expect(changes, hasLength(2));
|
||||
expect(changes.map((c) => c.changeType), everyElement('move'));
|
||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||
expect(changes, hasLength(2));
|
||||
expect(changes.map((c) => c.changeType), everyElement('move'));
|
||||
|
||||
final destinations =
|
||||
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
||||
expect(destinations, containsAll(['Archive', 'Trash']));
|
||||
final destinations =
|
||||
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
||||
expect(destinations, containsAll(['Archive', 'Trash']));
|
||||
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
expect(
|
||||
email!.mailboxPath,
|
||||
anyOf('Archive', 'Trash'),
|
||||
reason:
|
||||
'email must be optimistically moved to one of the two destinations',
|
||||
);
|
||||
},
|
||||
);
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
expect(
|
||||
email!.mailboxPath,
|
||||
anyOf('Archive', 'Trash'),
|
||||
reason:
|
||||
'email must be optimistically moved to one of the two destinations',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('IMAP SMTP auth failure', () {
|
||||
|
||||
@@ -61,7 +61,10 @@ abstract class MailboxRepositoryContract {
|
||||
|
||||
test('findMailboxByRole returns null when no match', () async {
|
||||
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 {
|
||||
|
||||
@@ -486,11 +486,8 @@ void main() {
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
final result = await r.mailboxes.createMailboxWithRole(
|
||||
'jmap-1',
|
||||
'Archive',
|
||||
'archive',
|
||||
);
|
||||
final result = await r.mailboxes
|
||||
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
|
||||
|
||||
expect(result.name, 'Archive');
|
||||
expect(result.role, 'archive');
|
||||
@@ -501,80 +498,81 @@ void main() {
|
||||
expect(found!.name, 'Archive');
|
||||
});
|
||||
|
||||
test('JMAP: throws when server returns no created ID', () async {
|
||||
final r = _makeRepos(
|
||||
httpClient: _mockJmap(
|
||||
apiResponses: [
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': null,
|
||||
'notCreated': {
|
||||
'new-mailbox': {'type': 'serverFail'},
|
||||
test(
|
||||
'JMAP: throws when server returns no created ID',
|
||||
() async {
|
||||
final r = _makeRepos(
|
||||
httpClient: _mockJmap(
|
||||
apiResponses: [
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': null,
|
||||
'notCreated': {
|
||||
'new-mailbox': {'type': 'serverFail'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
'0',
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
await expectLater(
|
||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
await expectLater(
|
||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('syncMailboxes IMAP preserves manually-set role', () {
|
||||
test(
|
||||
'existing role is kept when server returns no special-use flag',
|
||||
() async {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
// Make listMailboxes return a plain folder without \Archive.
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
test('existing role is kept when server returns no special-use flag',
|
||||
() async {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
// Make listMailboxes return a plain folder without \Archive.
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
|
||||
// Override listMailboxes to return one plain folder.
|
||||
final fakeClient = _PlainArchiveImapClient();
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: (_, __, ___) async => fakeClient,
|
||||
);
|
||||
await accounts.addAccount(_account, 'pw');
|
||||
// Override listMailboxes to return one plain folder.
|
||||
final fakeClient = _PlainArchiveImapClient();
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: (_, __, ___) async => fakeClient,
|
||||
);
|
||||
await accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
path: 'Archive',
|
||||
name: 'Archive',
|
||||
role: const Value('archive'),
|
||||
),
|
||||
);
|
||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
path: 'Archive',
|
||||
name: 'Archive',
|
||||
role: const Value('archive'),
|
||||
),
|
||||
);
|
||||
|
||||
await mailboxes.syncMailboxes('acc-1');
|
||||
await mailboxes.syncMailboxes('acc-1');
|
||||
|
||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
||||
expect(
|
||||
found,
|
||||
isNotNull,
|
||||
reason: 'Manually-set role should be preserved after sync',
|
||||
);
|
||||
expect(found!.path, 'Archive');
|
||||
// Suppress unused warning on spy.
|
||||
expect(spy, isNotNull);
|
||||
},
|
||||
);
|
||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
||||
expect(
|
||||
found,
|
||||
isNotNull,
|
||||
reason: 'Manually-set role should be preserved after sync',
|
||||
);
|
||||
expect(found!.path, 'Archive');
|
||||
// Suppress unused warning on spy.
|
||||
expect(spy, isNotNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 37);
|
||||
expect(db.schemaVersion, 36);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -178,17 +178,17 @@ void main() {
|
||||
|
||||
// v28: mime_tree_json column on email_bodies.
|
||||
await db
|
||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
||||
.customSelect(
|
||||
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||
)
|
||||
.get();
|
||||
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns = await _tableColumns(
|
||||
db,
|
||||
'sync_log_mailboxes',
|
||||
);
|
||||
final syncLogMailboxColumns =
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v32: local_sieve_applied table.
|
||||
@@ -209,22 +209,19 @@ void main() {
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
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();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test(
|
||||
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
||||
() async {
|
||||
final dbFile = File('test_migration_v22.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
||||
() async {
|
||||
final dbFile = File('test_migration_v22.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
|
||||
// Build a v22 database schema directly with raw SQL.
|
||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||
rawDb.execute('''
|
||||
// Build a v22 database schema directly with raw SQL.
|
||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||
rawDb.execute('''
|
||||
CREATE TABLE accounts (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
@@ -245,7 +242,7 @@ void main() {
|
||||
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE drafts (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NULL,
|
||||
@@ -257,7 +254,7 @@ void main() {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE mailboxes (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
@@ -268,7 +265,7 @@ void main() {
|
||||
role TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE emails (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
@@ -292,7 +289,7 @@ void main() {
|
||||
snoozed_from_mailbox_path TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE threads (
|
||||
account_id TEXT NOT NULL,
|
||||
mailbox_path TEXT NOT NULL,
|
||||
@@ -309,7 +306,7 @@ void main() {
|
||||
PRIMARY KEY (account_id, mailbox_path, id)
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE email_bodies (
|
||||
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
|
||||
text_body TEXT NULL,
|
||||
@@ -319,7 +316,7 @@ void main() {
|
||||
headers_json TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE sync_logs (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL,
|
||||
@@ -336,7 +333,7 @@ void main() {
|
||||
protocol_log TEXT NULL
|
||||
);
|
||||
''');
|
||||
rawDb.execute('''
|
||||
rawDb.execute('''
|
||||
CREATE TABLE sync_log_mailboxes (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
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
|
||||
);
|
||||
''');
|
||||
rawDb.execute('PRAGMA user_version = 22;');
|
||||
rawDb.close();
|
||||
rawDb.execute('PRAGMA user_version = 22;');
|
||||
rawDb.close();
|
||||
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
// Trigger migration.
|
||||
await db.select(db.accounts).get();
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
// Trigger migration.
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
final emailColumns = await _tableColumns(db, 'emails');
|
||||
expect(emailColumns, contains('list_unsubscribe_header'));
|
||||
final emailColumns = await _tableColumns(db, 'emails');
|
||||
expect(emailColumns, contains('list_unsubscribe_header'));
|
||||
|
||||
final draftColumns = await _tableColumns(db, 'drafts');
|
||||
expect(draftColumns, contains('imap_server_id'));
|
||||
final draftColumns = await _tableColumns(db, 'drafts');
|
||||
expect(draftColumns, contains('imap_server_id'));
|
||||
|
||||
// v25: new indexes on mailboxes and threads.
|
||||
final allIndexes = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
.get();
|
||||
final indexNames =
|
||||
allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||
expect(indexNames, contains('mailboxes_account_id'));
|
||||
expect(indexNames, contains('threads_latest_date'));
|
||||
// v25: new indexes on mailboxes and threads.
|
||||
final allIndexes = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
.get();
|
||||
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||
expect(indexNames, contains('mailboxes_account_id'));
|
||||
expect(indexNames, contains('threads_latest_date'));
|
||||
|
||||
// v26: FTS5 virtual table and triggers.
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
);
|
||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||
// v26: FTS5 virtual table and triggers.
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
);
|
||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||
|
||||
// v27: search_history_entries table.
|
||||
await db
|
||||
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||
.get();
|
||||
// v27: search_history_entries table.
|
||||
await db
|
||||
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||
.get();
|
||||
|
||||
// v28: mime_tree_json column on email_bodies.
|
||||
await db
|
||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
||||
.get();
|
||||
// v28: mime_tree_json column on email_bodies.
|
||||
await db
|
||||
.customSelect(
|
||||
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||
)
|
||||
.get();
|
||||
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
// v29: local_sieve_scripts table.
|
||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns = await _tableColumns(
|
||||
db,
|
||||
'sync_log_mailboxes',
|
||||
);
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns =
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
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();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
},
|
||||
);
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 37', () async {
|
||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -453,7 +443,6 @@ void main() {
|
||||
'share_keys', // v31
|
||||
'local_sieve_applied', // v32
|
||||
'user_preferences', // v34
|
||||
'image_trusted_senders', // v37
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -464,10 +453,8 @@ void main() {
|
||||
expect(draftColumns, contains('imap_server_id'));
|
||||
|
||||
// v30: duration_ms column on sync_log_mailboxes.
|
||||
final syncLogMailboxColumns = await _tableColumns(
|
||||
db,
|
||||
'sync_log_mailboxes',
|
||||
);
|
||||
final syncLogMailboxColumns =
|
||||
await _tableColumns(db, 'sync_log_mailboxes');
|
||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||
|
||||
// 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.
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,15 +9,14 @@ void main() {
|
||||
// absent at startup, throwing MissingPluginException (or a similar error).
|
||||
// initNotifications() must absorb the failure and let the app continue.
|
||||
test(
|
||||
'initNotifications completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native plugin is not registered, so
|
||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||
// false. This test fails before the fix (exception propagates) and passes
|
||||
// after it (exception is swallowed).
|
||||
await expectLater(initNotifications(), completes);
|
||||
},
|
||||
);
|
||||
'initNotifications completes without throwing when plugin is unavailable',
|
||||
() async {
|
||||
// In the unit-test environment the native plugin is not registered, so
|
||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||
// false. This test fails before the fix (exception propagates) and passes
|
||||
// after it (exception is swallowed).
|
||||
await expectLater(initNotifications(), completes);
|
||||
});
|
||||
|
||||
test('showNewMailNotification completes without throwing', () async {
|
||||
// Platform.isAndroid is false in tests, so this returns early without
|
||||
|
||||
@@ -103,9 +103,6 @@ class _FakeEmails implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
|
||||
@@ -26,9 +26,11 @@ class _FakeAccounts implements AccountRepository {
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
||||
@override
|
||||
Future<Account?> getAccount(String id) async => accounts
|
||||
.cast<Account?>()
|
||||
.firstWhere((a) => a?.id == id, orElse: () => null);
|
||||
Future<Account?> getAccount(String id) async =>
|
||||
accounts.cast<Account?>().firstWhere(
|
||||
(a) => a?.id == id,
|
||||
orElse: () => null,
|
||||
);
|
||||
@override
|
||||
Future<void> addAccount(Account account, String password) async {}
|
||||
@override
|
||||
@@ -92,7 +94,11 @@ class _CountingEmails implements EmailRepository {
|
||||
@override
|
||||
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
||||
@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([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
@@ -102,9 +108,6 @@ class _CountingEmails implements EmailRepository {
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
|
||||
@@ -47,7 +47,9 @@ void main() {
|
||||
test('parsePublicKeyQr returns null for invalid input', () {
|
||||
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
|
||||
expect(
|
||||
ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
|
||||
ShareEncryptionService.parsePublicKeyQr(
|
||||
'sharedinbox.de:pubkey:v1:!!!',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -73,7 +73,11 @@ void main() {
|
||||
SieveRule(
|
||||
joinType: 'single',
|
||||
conditions: [
|
||||
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
|
||||
HeaderCondition(
|
||||
['from', 'reply-to'],
|
||||
':is',
|
||||
['boss@work.com'],
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
FlagAction([r'\Important']),
|
||||
@@ -117,10 +121,8 @@ void main() {
|
||||
),
|
||||
];
|
||||
|
||||
final ctx = interp.execute(
|
||||
rules,
|
||||
_email(subject: 'Weekly Newsletter Issue'),
|
||||
);
|
||||
final ctx =
|
||||
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
|
||||
expect(ctx.targetFolders, contains('Bulk'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,9 +261,8 @@ if exists "X-Spam-Flag" {
|
||||
|
||||
group('SieveParser — rule model', () {
|
||||
test('simple if produces one rule with branchGroupId', () {
|
||||
final rules = parser.parse(
|
||||
'if header :contains "Subject" "x" { discard; }',
|
||||
);
|
||||
final rules =
|
||||
parser.parse('if header :contains "Subject" "x" { discard; }');
|
||||
expect(rules, hasLength(1));
|
||||
expect(rules.first.branchGroupId, isNotNull);
|
||||
expect(rules.first.conditions, hasLength(1));
|
||||
|
||||
@@ -127,35 +127,33 @@ void main() {
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
});
|
||||
|
||||
test(
|
||||
'stores and retrieves stackTrace and isPermanent on error entries',
|
||||
() async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 9);
|
||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||
test('stores and retrieves stackTrace and isPermanent on error entries',
|
||||
() async {
|
||||
final repo = SyncLogRepositoryImpl(db);
|
||||
final start = DateTime(2024, 3, 1, 9);
|
||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: fakeTrace,
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 0,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
await repo.log(
|
||||
accountId: 'acc1',
|
||||
success: false,
|
||||
errorMessage: 'MissingPluginException',
|
||||
stackTrace: fakeTrace,
|
||||
isPermanent: true,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
mailboxesSynced: 0,
|
||||
pendingFlushed: 0,
|
||||
bytesTransferred: 0,
|
||||
startedAt: start,
|
||||
finishedAt: end,
|
||||
);
|
||||
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(entry.stackTrace, fakeTrace);
|
||||
expect(entry.isPermanent, true);
|
||||
expect(entry.errorMessage, 'MissingPluginException');
|
||||
},
|
||||
);
|
||||
final entries = await repo.observeSyncLogs('acc1').first;
|
||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||
expect(entry.stackTrace, fakeTrace);
|
||||
expect(entry.isPermanent, true);
|
||||
expect(entry.errorMessage, 'MissingPluginException');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -260,9 +260,8 @@ void main() {
|
||||
expect(original!.messageId, isNull); // set a messageId so lookup works
|
||||
|
||||
// Seed a messageId so undo can find the email after UID change.
|
||||
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write(
|
||||
const EmailsCompanion(messageId: Value('msg-101@test')),
|
||||
);
|
||||
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
|
||||
.write(const EmailsCompanion(messageId: Value('msg-101@test')));
|
||||
|
||||
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
||||
|
||||
@@ -304,9 +303,8 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 4. Verify the current email row is now in INBOX.
|
||||
final inInbox = await (db.select(
|
||||
db.emails,
|
||||
)..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
final inInbox = await (db.select(db.emails)
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(
|
||||
inInbox,
|
||||
|
||||
@@ -122,74 +122,70 @@ void main() {
|
||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'undo pushes inverse action into log when destinationMailboxPath is set',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'del1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.delete,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
destinationMailboxPath: 'Trash',
|
||||
);
|
||||
test('undo pushes inverse action into log when destinationMailboxPath is set',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'del1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.delete,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
destinationMailboxPath: 'Trash',
|
||||
);
|
||||
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'del1');
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'del1');
|
||||
|
||||
// Original entry stays; inverse is added.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 2);
|
||||
expect(log[0].id, 'del1');
|
||||
final inv = log[1];
|
||||
expect(inv.id, 'del1-inv');
|
||||
expect(inv.type, UndoType.move);
|
||||
expect(inv.emailIds, ['e1']);
|
||||
expect(inv.sourceMailboxPath, 'Trash');
|
||||
expect(inv.destinationMailboxPath, 'INBOX');
|
||||
verify(
|
||||
mockUndoRepo.saveAction(
|
||||
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
// Original entry stays; inverse is added.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 2);
|
||||
expect(log[0].id, 'del1');
|
||||
final inv = log[1];
|
||||
expect(inv.id, 'del1-inv');
|
||||
expect(inv.type, UndoType.move);
|
||||
expect(inv.emailIds, ['e1']);
|
||||
expect(inv.sourceMailboxPath, 'Trash');
|
||||
expect(inv.destinationMailboxPath, 'INBOX');
|
||||
verify(
|
||||
mockUndoRepo.saveAction(
|
||||
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'undo without destinationMailboxPath does not push inverse action',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'mv1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.move,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
// no destinationMailboxPath
|
||||
);
|
||||
test('undo without destinationMailboxPath does not push inverse action',
|
||||
() async {
|
||||
final action = UndoAction(
|
||||
id: 'mv1',
|
||||
accountId: 'acc1',
|
||||
type: UndoType.move,
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
// no destinationMailboxPath
|
||||
);
|
||||
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockEmailRepo.cancelPendingChange(any, any),
|
||||
).thenAnswer((_) async => false);
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'mv1');
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
await notifier.init();
|
||||
await notifier.pushAction(action);
|
||||
await notifier.undo(actionId: 'mv1');
|
||||
|
||||
// Original entry stays; no inverse since no destinationMailboxPath.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 1);
|
||||
expect(log.first.id, 'mv1');
|
||||
},
|
||||
);
|
||||
// Original entry stays; no inverse since no destinationMailboxPath.
|
||||
final log = container.read(undoServiceProvider);
|
||||
expect(log.length, 1);
|
||||
expect(log.first.id, 'mv1');
|
||||
});
|
||||
|
||||
test('undo with actionId removes and undos specific action', () async {
|
||||
// action1 has no destination → no inverse action
|
||||
@@ -354,9 +350,13 @@ void main() {
|
||||
);
|
||||
|
||||
// Simulate slow DB load
|
||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer(
|
||||
(_) =>
|
||||
Future.delayed(const Duration(milliseconds: 10), () => [persisted]),
|
||||
when(
|
||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||
).thenAnswer(
|
||||
(_) => Future.delayed(
|
||||
const Duration(milliseconds: 10),
|
||||
() => [persisted],
|
||||
),
|
||||
);
|
||||
|
||||
final notifier = container.read(undoServiceProvider.notifier);
|
||||
|
||||
@@ -109,19 +109,6 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.EmailThread>> observeAllInboxThreads({
|
||||
int? limit = 50,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeAllInboxThreads,
|
||||
[],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||
String? accountId,
|
||||
|
||||
@@ -46,9 +46,8 @@ class ThrowingUrlLauncher extends Mock
|
||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts),
|
||||
),
|
||||
accountRepositoryProvider
|
||||
.overrideWithValue(FakeAccountRepository(accounts)),
|
||||
],
|
||||
child: const MaterialApp(home: AboutScreen()),
|
||||
);
|
||||
@@ -152,10 +151,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildScreen());
|
||||
@@ -176,7 +173,10 @@ void main() {
|
||||
expect(clipboardText, contains('Locale'));
|
||||
expect(clipboardText, contains('Text Scale'));
|
||||
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', (
|
||||
|
||||
@@ -74,7 +74,10 @@ void main() {
|
||||
recipientKeyId: material.keyId,
|
||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
||||
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.pumpAndSettle();
|
||||
|
||||
expect(find.text('Imported 1 account successfully.'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Imported 1 account successfully.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -227,53 +227,30 @@ void main() {
|
||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows discrepancy details when sync health has discrepancies',
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
const summary =
|
||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: false,
|
||||
discrepancySummary: summary,
|
||||
testWidgets(
|
||||
'shows discrepancy details when sync health has discrepancies',
|
||||
(tester) async {
|
||||
const summary =
|
||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: false,
|
||||
discrepancySummary: summary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('sync health row is positioned below the account name row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
||||
expect(healthPos, greaterThan(namePos));
|
||||
});
|
||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,10 +96,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const exception = 'TestException: clipboard test';
|
||||
@@ -128,77 +126,79 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('CrashScreen shows git hash as clickable link above stacktrace', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
testWidgets(
|
||||
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
final mock = MockUrlLauncher();
|
||||
UrlLauncherPlatform.instance = mock;
|
||||
|
||||
const exception = 'TestException: git hash test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
const exception = 'TestException: git hash test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const testHash = 'abc1234';
|
||||
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(
|
||||
CrashScreen(
|
||||
exception: exception,
|
||||
stackTrace: stackTrace,
|
||||
gitHash: testHash,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(gitLinkFinder, findsOneWidget);
|
||||
// Git hash link should be present
|
||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||
expect(gitLinkFinder, findsOneWidget);
|
||||
|
||||
// Link must appear above the stack trace
|
||||
final stackTraceFinder = find.text('Stack Trace:');
|
||||
expect(
|
||||
tester.getTopLeft(gitLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||
);
|
||||
// Link must appear above the stack trace
|
||||
final stackTraceFinder = find.text('Stack Trace:');
|
||||
expect(
|
||||
tester.getTopLeft(gitLinkFinder).dy,
|
||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||
);
|
||||
|
||||
// Tapping the link should open the Codeberg commit URL
|
||||
await tester.tap(gitLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
// Tapping the link should open the Codeberg commit URL
|
||||
await tester.tap(gitLinkFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
mock.launchedUrl,
|
||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
testWidgets(
|
||||
'CrashScreen shows version, build mode, and platform in the UI',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const exception = 'TestException: info row test';
|
||||
final stackTrace = StackTrace.current;
|
||||
const exception = 'TestException: info row test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Info row shows app version (from mock), build mode, and platform OS.
|
||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||
// In test builds kDebugMode is true.
|
||||
expect(find.textContaining('debug'), findsOneWidget);
|
||||
// Platform OS is always present (linux in CI, android/ios on device).
|
||||
expect(
|
||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
// Info row shows app version (from mock), build mode, and platform OS.
|
||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||
// In test builds kDebugMode is true.
|
||||
expect(find.textContaining('debug'), findsOneWidget);
|
||||
// Platform OS is always present (linux in CI, android/ios on device).
|
||||
expect(
|
||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||
findsWidgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows app version as clickable link when git hash is set',
|
||||
@@ -264,10 +264,8 @@ void main() {
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
() => tester.binding.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
const exception = 'TestException: version link clipboard test';
|
||||
|
||||
@@ -106,62 +106,62 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'try connection button is disabled when no password stored or entered',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
'try connection button is disabled when no password stored or entered',
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
},
|
||||
);
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'try connection button is enabled after typing password with no stored password',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
'try connection button is enabled after typing password with no stored password',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/edit',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
hasStoredPassword: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('editPasswordField')),
|
||||
'mypassword',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('editPasswordField')),
|
||||
'mypassword',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
},
|
||||
);
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byKey(const Key('editTryConnectionButton')),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('save button is disabled when no password stored or entered', (
|
||||
tester,
|
||||
@@ -182,9 +182,8 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Save'),
|
||||
);
|
||||
final button = tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ List<Override> _overrides({required EmailBody body, Email? email}) => [
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
|
||||
FakeEmailRepository(
|
||||
emailDetail: email ?? testEmail(),
|
||||
emailBody: body,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -188,45 +191,45 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'),
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply all',
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Reply on single-recipient email navigates directly to compose',
|
||||
(tester) async {
|
||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||
// only bob remains → no dialog, navigate straight to compose.
|
||||
final email = testEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: [
|
||||
..._overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets('Reply on single-recipient email navigates directly to compose',
|
||||
(tester) async {
|
||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||
// only bob remains → no dialog, navigate straight to compose.
|
||||
final email = testEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: [
|
||||
..._overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No dialog shown — straight navigation to compose.
|
||||
expect(find.text('Reply All'), findsNothing);
|
||||
},
|
||||
);
|
||||
// No dialog shown — straight navigation to compose.
|
||||
expect(find.text('Reply All'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Reply on multi-recipient email shows Reply All dialog', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('Reply on multi-recipient email shows Reply All dialog',
|
||||
(tester) async {
|
||||
// Email with an extra Cc recipient so the dialog is triggered.
|
||||
final email = Email(
|
||||
id: 'acc-1:42',
|
||||
@@ -255,7 +258,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Reply',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -266,9 +271,7 @@ void main() {
|
||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam is in popup menu, not a standalone button', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('Mark as spam button is present in app bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -279,24 +282,16 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No standalone icon button for mark as spam.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
findsNothing,
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// It appears in the popup menu.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mark as spam'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam shows dialog when no junk folder', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||
(tester) async {
|
||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||
// returns null → dialog shown.
|
||||
await tester.pumpWidget(
|
||||
@@ -309,11 +304,11 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Open the popup menu first, then tap Mark as spam.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mark as spam'));
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No spam folder found'), findsOneWidget);
|
||||
@@ -331,7 +326,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Archive',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
@@ -350,16 +347,17 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Archive',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No archive folder found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mark as unread is in popup menu, not a standalone button', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('Mark as unread is in popup menu, not a standalone button',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -395,16 +393,13 @@ void main() {
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emailDetail: testEmail(),
|
||||
emailBody: const EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
attachments: [],
|
||||
),
|
||||
emailBody:
|
||||
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
rawRfc822: rawContent,
|
||||
),
|
||||
),
|
||||
@@ -433,16 +428,13 @@ void main() {
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emailDetail: testEmail(),
|
||||
emailBody: const EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
attachments: [],
|
||||
),
|
||||
emailBody:
|
||||
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
rawRfc822: 'Subject: test\r\n\r\nBody',
|
||||
),
|
||||
),
|
||||
@@ -483,37 +475,43 @@ void main() {
|
||||
expect(find.text('Share'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('long-press on unsubscribe chip shows URL tooltip', (
|
||||
tester,
|
||||
) async {
|
||||
final email = testEmail(
|
||||
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
testWidgets(
|
||||
'long-press on unsubscribe chip shows URL tooltip',
|
||||
(tester) async {
|
||||
final email = testEmail(
|
||||
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
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(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.longPress(find.text('Unsubscribe'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Unsubscribe'));
|
||||
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', (
|
||||
tester,
|
||||
@@ -557,31 +555,36 @@ void main() {
|
||||
expect(find.textContaining('application/pdf'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', (
|
||||
tester,
|
||||
) async {
|
||||
const body = EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
textBody: 'Hello',
|
||||
attachments: [],
|
||||
// mimeTree is null — not yet cached or not available.
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(body: body),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets(
|
||||
'Show Mail Structure shows snackbar when mimeTree is absent',
|
||||
(tester) async {
|
||||
const body = EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
textBody: 'Hello',
|
||||
attachments: [],
|
||||
// mimeTree is null — not yet cached or not available.
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(body: body),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Show Mail Structure'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Show Mail Structure'));
|
||||
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(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
|
||||
syncLastErrorProvider.overrideWith(
|
||||
(ref, _) => Stream.value(syncError),
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
@@ -120,7 +122,9 @@ void main() {
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: _overrides(
|
||||
searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')],
|
||||
searchResults: [
|
||||
_email(id: 'acc-1:5', subject: 'Project proposal'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -430,62 +430,63 @@ void main() {
|
||||
expect(find.text('Result email'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('deleting all search results pops back to previous screen', (
|
||||
tester,
|
||||
) async {
|
||||
final email = testEmail(subject: 'Needle');
|
||||
testWidgets(
|
||||
'deleting all search results pops back to previous screen',
|
||||
(tester) async {
|
||||
final email = testEmail(subject: 'Needle');
|
||||
|
||||
// Start at the mailbox list so the email list is pushed on top of it,
|
||||
// making context.canPop() == true inside EmailListScreen.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(searchResults: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// Start at the mailbox list so the email list is pushed on top of it,
|
||||
// making context.canPop() == true inside EmailListScreen.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(searchResults: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
|
||||
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
||||
await tester.tap(find.text('INBOX'));
|
||||
await tester.pumpAndSettle();
|
||||
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
||||
await tester.tap(find.text('INBOX'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EmailListScreen), findsOneWidget);
|
||||
expect(find.byType(EmailListScreen), findsOneWidget);
|
||||
|
||||
// Search for the email.
|
||||
await tester.enterText(find.byType(TextField), 'Needle');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
// Search for the email.
|
||||
await tester.enterText(find.byType(TextField), 'Needle');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 'Needle' also appears in the SearchBar input, so match at least one.
|
||||
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
||||
// 'Needle' also appears in the SearchBar input, so match at least one.
|
||||
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
||||
|
||||
// Long-press the sender name (unique to the email tile) to enter
|
||||
// selection mode.
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
// Long-press the sender name (unique to the email tile) to enter
|
||||
// selection mode.
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.select_all));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.select_all));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.delete));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have popped back to the mailbox list.
|
||||
expect(find.byType(EmailListScreen), findsNothing);
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
});
|
||||
// Should have popped back to the mailbox list.
|
||||
expect(find.byType(EmailListScreen), findsNothing);
|
||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'deleting some search results updates the list without popping',
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -245,10 +245,6 @@ class FakeEmailRepository implements EmailRepository {
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
@@ -631,13 +627,11 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
List<String>? trustedImageSenders,
|
||||
}) : _trustedImageSenders = trustedImageSenders ?? [];
|
||||
});
|
||||
|
||||
MenuPosition menuPosition;
|
||||
MenuPosition mailViewButtonPosition;
|
||||
AfterMailViewAction afterMailViewAction;
|
||||
final List<String> _trustedImageSenders;
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
@@ -662,23 +656,6 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
||||
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 {
|
||||
|
||||
@@ -89,7 +89,9 @@ void main() {
|
||||
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');
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
@@ -120,7 +122,9 @@ void main() {
|
||||
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(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
|
||||
@@ -20,12 +20,10 @@ Widget _wrap(Widget child) => MaterialApp(
|
||||
|
||||
void main() {
|
||||
group('buildEmailHtml', () {
|
||||
test(
|
||||
'forces light color-scheme to prevent black-on-black in dark mode',
|
||||
() {
|
||||
_expectLightMode(buildEmailHtml('<p>Hello</p>'));
|
||||
},
|
||||
);
|
||||
test('forces light color-scheme to prevent black-on-black in dark mode',
|
||||
() {
|
||||
_expectLightMode(buildEmailHtml('<p>Hello</p>'));
|
||||
});
|
||||
|
||||
test('includes email body content', () {
|
||||
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',
|
||||
() {
|
||||
final html = buildEmailHtml(
|
||||
'<table width="600"><tr><td>x</td></tr></table>',
|
||||
);
|
||||
final html =
|
||||
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
|
||||
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
||||
expect(html, contains('overflow-x: hidden'));
|
||||
// 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)', () {
|
||||
testWidgets('renders extracted text from HTML', (tester) async {
|
||||
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('world'), findsOneWidget);
|
||||
@@ -91,11 +92,12 @@ void main() {
|
||||
expect(find.byType(SelectableText), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('toggling loadRemoteImages rebuilds without error', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('toggling loadRemoteImages rebuilds without error',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(const SecureEmailWebView(htmlBody: '<p>Body</p>')),
|
||||
_wrap(
|
||||
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
@@ -109,7 +111,9 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,13 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
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();
|
||||
|
||||
@@ -38,9 +38,8 @@ void main() {
|
||||
sourceMailboxPath: 'INBOX',
|
||||
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
);
|
||||
when(
|
||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||
).thenAnswer((_) async => [staleAction]);
|
||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
||||
.thenAnswer((_) async => [staleAction]);
|
||||
|
||||
await tester.pumpWidget(buildShell(mockUndoRepo));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -49,12 +48,10 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows snackbar for fresh action pushed in current session', (
|
||||
tester,
|
||||
) async {
|
||||
when(
|
||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||
).thenAnswer((_) async => []);
|
||||
testWidgets('shows snackbar for fresh action pushed in current session',
|
||||
(tester) async {
|
||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
await tester.pumpWidget(buildShell(mockUndoRepo));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -67,20 +64,18 @@ void main() {
|
||||
emailIds: ['e1'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
);
|
||||
await ProviderScope.containerOf(
|
||||
context,
|
||||
).read(undoServiceProvider.notifier).pushAction(freshAction);
|
||||
await ProviderScope.containerOf(context)
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(freshAction);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 email(s) moved'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows correct text for delete action (moved to Trash)', (
|
||||
tester,
|
||||
) async {
|
||||
when(
|
||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||
).thenAnswer((_) async => []);
|
||||
testWidgets('shows correct text for delete action (moved to Trash)',
|
||||
(tester) async {
|
||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
await tester.pumpWidget(buildShell(mockUndoRepo));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -93,9 +88,9 @@ void main() {
|
||||
emailIds: ['e1', 'e2'],
|
||||
sourceMailboxPath: 'INBOX',
|
||||
);
|
||||
await ProviderScope.containerOf(
|
||||
context,
|
||||
).read(undoServiceProvider.notifier).pushAction(deleteAction);
|
||||
await ProviderScope.containerOf(context)
|
||||
.read(undoServiceProvider.notifier)
|
||||
.pushAction(deleteAction);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 email(s) moved to Trash'), findsOneWidget);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user