Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069722ce2f | ||
|
|
eba94f2aa7 | ||
|
|
e251c74139 | ||
|
|
385c2234ee | ||
|
|
02b9635c83 |
@@ -6,6 +6,12 @@
|
|||||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
|
# Infrastructure tools required by CI workflows
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
stunnel4 \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|||||||
+148
-3
@@ -1,14 +1,159 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on: [push, pull_request]
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'test/**'
|
||||||
|
- 'integration_test/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '!assets/changelog.txt'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'analysis_options.yaml'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'stalwart-dev/**'
|
||||||
|
- 'ci/**'
|
||||||
|
- 'Taskfile.yml'
|
||||||
|
- 'drift_schemas/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'test/**'
|
||||||
|
- 'integration_test/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- '!assets/changelog.txt'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'analysis_options.yaml'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'stalwart-dev/**'
|
||||||
|
- 'ci/**'
|
||||||
|
- 'Taskfile.yml'
|
||||||
|
- 'drift_schemas/**'
|
||||||
|
- '.forgejo/workflows/ci.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Dagger Remote Engine
|
with:
|
||||||
|
fetch-depth: 50
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Locate Docker daemon for local Dagger engine
|
||||||
|
run: |
|
||||||
|
# Skip if remote Dagger engine is already configured (preferred path)
|
||||||
|
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
||||||
|
echo "Remote Dagger engine configured, no local Docker needed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try host Docker socket (DooD) if runner mounts it
|
||||||
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker available via host socket."
|
||||||
|
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
||||||
|
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
||||||
|
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
||||||
|
echo "CI will likely fail at the Dagger step." >&2
|
||||||
|
|
||||||
|
- name: Prune Dagger cache before check
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
||||||
|
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
||||||
|
run: |
|
||||||
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||||
|
|
||||||
- name: Run Full Check Suite
|
- name: Run Full Check Suite
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
run: task check-dagger
|
run: task check-dagger
|
||||||
|
|
||||||
|
- name: Prune Dagger cache after check
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: |
|
||||||
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
|
merge-renovate:
|
||||||
|
name: Auto-merge Renovate PR
|
||||||
|
needs: [check]
|
||||||
|
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Merge if automerge label is set
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
python3 - << 'PYEOF'
|
||||||
|
import os, json, urllib.request, urllib.error, sys
|
||||||
|
|
||||||
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
pr_number = os.environ["PR_NUMBER"]
|
||||||
|
api = f"{url_base}/api/v1/repos/{repo}"
|
||||||
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
labels = [l["name"] for l in json.loads(r.read())]
|
||||||
|
|
||||||
|
if "automerge" not in labels:
|
||||||
|
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
body = json.dumps({"Do": "merge"}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{api}/pulls/{pr_number}/merge",
|
||||||
|
data=body, headers=headers, method="POST"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
print(f"PR #{pr_number} merged successfully")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err = e.read().decode()
|
||||||
|
if "already been merged" in err or "has been merged" in err:
|
||||||
|
print(f"PR #{pr_number} already merged — OK")
|
||||||
|
else:
|
||||||
|
print(f"Merge failed: {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
PYEOF
|
||||||
|
|||||||
@@ -106,17 +106,28 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Publish Android to Play Store
|
- name: Publish Android to Play Store
|
||||||
|
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||||
env:
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
@@ -134,17 +145,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy APK to server
|
- name: Build & Deploy APK to server
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-apk
|
run: task deploy-apk
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux Release
|
name: Build Linux Release
|
||||||
@@ -162,17 +187,29 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy Linux to server
|
- name: Build & Deploy Linux to server
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-linux
|
run: task deploy-linux
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
label-deploy-health:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
|
|||||||
@@ -58,18 +58,28 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
|
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task test-android-firebase
|
run: task test-android-firebase
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|
||||||
- name: Create issue on test failure
|
- name: Create issue on test failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
env:
|
env:
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
|
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
||||||
run: task renovate
|
run: task renovate
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|||||||
@@ -26,18 +26,32 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||||
|
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||||
|
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||||
|
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Update Website
|
- name: Build & Update Website
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-website
|
run: task publish-website
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Verify Website
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: scripts/website-verify.sh
|
||||||
|
|
||||||
|
- name: Cleanup TLS credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
|
|||||||
@@ -8,41 +8,46 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
|||||||
|
|
||||||
## Issue Label Workflow
|
## Issue Label Workflow
|
||||||
|
|
||||||
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
We use issues, follow this label state machine:
|
||||||
|
|
||||||
| Label | Trigger | Outcome |
|
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||||
|---|---|---|
|
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
- **State/Ready** — Issue is approved and ready for implementation
|
||||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
- **State/InProgress** — Set while an agent (or human) is actively working
|
||||||
|
- **State/Question** — Agent hit a blocker or needs clarification
|
||||||
|
|
||||||
**State machine:**
|
Full lifecycle:
|
||||||
|
|
||||||
```
|
```
|
||||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||||
↘ NeedSupervisor (on failure)
|
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||||
|
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||||
loop/code → loop/code-in-progress → loop/code-done
|
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||||
↘ NeedSupervisor (on failure)
|
any state → State/Question (automated or manual: when blocked)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
List open issues ready to pick up:
|
||||||
|
|
||||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
|
||||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
|
||||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
|
||||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
|
||||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
|
||||||
|
|
||||||
**Typical lifecycle for a new feature:**
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||||
```
|
```
|
||||||
1. Create issue
|
|
||||||
2. Add label loop/plan → agent writes plan as comment
|
Rules:
|
||||||
3. Review plan, request changes or approve
|
|
||||||
4. Add label loop/code → agent implements + opens PR
|
- Never start implementation on an issue without `State/Ready`
|
||||||
5. Review PR, merge
|
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||||
6. Close issue
|
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
||||||
```
|
- When working via the agent loop: label transitions are set automatically
|
||||||
|
by `agent_loop.py` — do **not** set them yourself.
|
||||||
|
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||||
|
```bash
|
||||||
|
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||||
|
```
|
||||||
|
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||||
|
- When done and CI is green, close the issue:
|
||||||
|
```bash
|
||||||
|
fgj issue close <NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
|
|||||||
@@ -188,5 +188,3 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
|
|||||||
## Daily Workflow
|
## Daily Workflow
|
||||||
|
|
||||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||||
|
|
||||||
<!-- agentloop code test passed -->
|
|
||||||
|
|||||||
@@ -216,8 +216,3 @@ test/
|
|||||||
- **Settings** — list and remove accounts
|
- **Settings** — list and remove accounts
|
||||||
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
||||||
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
||||||
# CI Trigger
|
|
||||||
# CI Trigger 2
|
|
||||||
# Dummy commit to verify CI fixes
|
|
||||||
# Dummy commit 3
|
|
||||||
# CI Trigger 1780415300
|
|
||||||
|
|||||||
+2
-11
@@ -298,7 +298,7 @@ tasks:
|
|||||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||||
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||||
sleep 90
|
sleep 90
|
||||||
else
|
else
|
||||||
@@ -319,16 +319,7 @@ tasks:
|
|||||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
until [ -s "$PORTFILE" ]; do
|
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||||
sleep 0.05
|
|
||||||
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
|
||||||
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
|
||||||
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
|
||||||
RC=$?
|
|
||||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
|
||||||
exit $RC
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
PORT=$(cat "$PORTFILE")
|
PORT=$(cat "$PORTFILE")
|
||||||
retry_dagger env \
|
retry_dagger env \
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ android {
|
|||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlinOptions {
|
||||||
compilerOptions {
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
+1
-1
@@ -181,7 +181,7 @@ func New(
|
|||||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||||
func (m *Ci) toolchain() *dagger.Container {
|
func (m *Ci) toolchain() *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.44.0").
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
|
|||||||
@@ -4,18 +4,6 @@ This file contains tasks which got implemented.
|
|||||||
|
|
||||||
Tasks get moved from next.md to done.md
|
Tasks get moved from next.md to done.md
|
||||||
|
|
||||||
## Tasks (2026-05-29)
|
|
||||||
|
|
||||||
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
|
|
||||||
all features from PR #307 (issue #299) were already merged into main via separate PRs:
|
|
||||||
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
|
|
||||||
- Configurable back button position for single mail view — merged via #299/#307 features in #300
|
|
||||||
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
|
|
||||||
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
|
|
||||||
- User preferences DB schema (v34–v36: `user_preferences` table) — in main
|
|
||||||
- PR #307 and issue #299 closed.
|
|
||||||
- Issue #315 closed.
|
|
||||||
|
|
||||||
## Tasks (2026-05-26)
|
## Tasks (2026-05-26)
|
||||||
|
|
||||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||||
|
|||||||
@@ -92,9 +92,8 @@ class ShareEncryptionService {
|
|||||||
) {
|
) {
|
||||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
try {
|
try {
|
||||||
final data = Uint8List.fromList(
|
final data =
|
||||||
base64.decode(s.substring(_pubKeyPrefix.length)),
|
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||||
);
|
|
||||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||||
return (
|
return (
|
||||||
keyId: data.sublist(0, _keyIdLen),
|
keyId: data.sublist(0, _keyIdLen),
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ class SieveInterpreter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _globMatch(String value, String pattern) {
|
bool _globMatch(String value, String pattern) {
|
||||||
final regexStr = RegExp.escape(
|
final regexStr =
|
||||||
pattern,
|
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
|
||||||
return RegExp('^$regexStr\$').hasMatch(value);
|
return RegExp('^$regexStr\$').hasMatch(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -466,7 +466,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readTaggedArg() {
|
String readTaggedArg() {
|
||||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
throw SieveParseException('Expected tagged argument at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected tagged argument at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? peekSizeUnit() {
|
String? peekSizeUnit() {
|
||||||
@@ -478,7 +480,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readDigits() {
|
String readDigits() {
|
||||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
throw SieveParseException('Expected number at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected number at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final start = _pos;
|
final start = _pos;
|
||||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
@@ -489,7 +493,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readQuotedString() {
|
String readQuotedString() {
|
||||||
if (_src[_pos] != '"') {
|
if (_src[_pos] != '"') {
|
||||||
throw SieveParseException('Expected " at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected " at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_pos++; // skip opening quote
|
_pos++; // skip opening quote
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
|
|||||||
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
||||||
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
||||||
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
||||||
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
.replaceAll(
|
||||||
|
"src='cid:${bareCid.toLowerCase()}'",
|
||||||
|
"src='$dataUri'",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ class LocalSieveRepository {
|
|||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
|
||||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||||
final rows = await (_db.select(
|
final rows = await (_db.select(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
.get();
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
@@ -27,9 +26,10 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||||
final rowId = int.parse(blobId);
|
final rowId = int.parse(blobId);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where(
|
||||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (row == null) throw Exception('Local script not found: $blobId');
|
if (row == null) throw Exception('Local script not found: $blobId');
|
||||||
return row.content;
|
return row.content;
|
||||||
@@ -44,7 +44,9 @@ class LocalSieveRepository {
|
|||||||
if (id != null) {
|
if (id != null) {
|
||||||
final rowId = int.parse(id);
|
final rowId = int.parse(id);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.write(
|
.write(
|
||||||
LocalSieveScriptsCompanion(
|
LocalSieveScriptsCompanion(
|
||||||
name: Value(name),
|
name: Value(name),
|
||||||
@@ -76,9 +78,10 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.delete(
|
await (_db.delete(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where(
|
||||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,9 @@ class LocalSieveRepository {
|
|||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import 'package:sharedinbox/data/db/database.dart';
|
|||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
DraftRepositoryImpl(
|
||||||
: _imapConnect = imapConnect;
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn? imapConnect,
|
||||||
|
}) : _imapConnect = imapConnect;
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -121,7 +124,10 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
Future<void> _syncWithServer(
|
||||||
|
imap.ImapClient client,
|
||||||
|
String accountId,
|
||||||
|
) async {
|
||||||
// Create/select the Drafts folder.
|
// Create/select the Drafts folder.
|
||||||
try {
|
try {
|
||||||
await client.createMailbox('Drafts');
|
await client.createMailbox('Drafts');
|
||||||
@@ -156,9 +162,8 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
? uidList.first.toString()
|
? uidList.first.toString()
|
||||||
: null;
|
: null;
|
||||||
if (uid != null) {
|
if (uid != null) {
|
||||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||||
DraftsCompanion(imapServerId: Value(uid)),
|
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (threadEmails.isEmpty) return;
|
|
||||||
final latest = threadEmails.last;
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
// Collect unique participants across the whole thread.
|
// Collect unique participants across the whole thread.
|
||||||
@@ -238,12 +237,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||||
final msg = fetch.messages.firstOrNull;
|
final msg = fetch.messages.first;
|
||||||
if (msg == null) {
|
|
||||||
throw StateError(
|
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final textBody = msg.decodeTextPlainPart();
|
final textBody = msg.decodeTextPlainPart();
|
||||||
final rawHtml = msg.decodeTextHtmlPart();
|
final rawHtml = msg.decodeTextHtmlPart();
|
||||||
final htmlBody =
|
final htmlBody =
|
||||||
@@ -331,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
'bodyProperties': [
|
||||||
|
'partId',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'size',
|
||||||
|
'subParts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -1949,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
final alreadyApplied = await (_db.select(
|
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||||
_db.localSieveApplied,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
.get();
|
||||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
@@ -2051,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (destMailbox == null) {
|
if (destMailbox == null) {
|
||||||
log(
|
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||||
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
destPath = destMailbox.path;
|
destPath = destMailbox.path;
|
||||||
@@ -2811,13 +2808,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
final fetch = await client.uidFetchMessage(
|
||||||
final msg = fetch.messages.firstOrNull;
|
emailRow.uid,
|
||||||
if (msg == null) {
|
'BODY.PEEK[]',
|
||||||
throw StateError(
|
);
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
final msg = fetch.messages.first;
|
||||||
);
|
|
||||||
}
|
|
||||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||||
final bytes = part.decodeContentBinary();
|
final bytes = part.decodeContentBinary();
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
@@ -2879,14 +2874,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
final fetch = await client.uidFetchMessage(
|
||||||
final msg = fetch.messages.firstOrNull;
|
emailRow.uid,
|
||||||
if (msg == null) {
|
'BODY.PEEK[]',
|
||||||
throw StateError(
|
);
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
return fetch.messages.first.renderMessage();
|
||||||
);
|
|
||||||
}
|
|
||||||
return msg.renderMessage();
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
}
|
}
|
||||||
@@ -3260,17 +3252,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.emails)
|
||||||
_db.emails,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(
|
await (_db.delete(_db.pendingChanges)
|
||||||
_db.pendingChanges,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(
|
await (_db.delete(_db.syncStates)
|
||||||
_db.syncStates,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -82,9 +82,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||||
// folders the server doesn't tag with a special-use attribute.
|
// folders the server doesn't tag with a special-use attribute.
|
||||||
final existingRows = await (_db.select(
|
final existingRows = await (_db.select(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.accountId.equals(account.id)))
|
||||||
)..where((t) => t.accountId.equals(account.id)))
|
|
||||||
.get();
|
.get();
|
||||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||||
|
|
||||||
@@ -321,9 +320,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {
|
Future<void> clearForResync(String accountId) async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,9 +367,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
role: Value(role),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
|
||||||
_db.mailboxes,
|
|
||||||
)..where((t) => t.id.equals(id)))
|
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return _toModel(row);
|
return _toModel(row);
|
||||||
}
|
}
|
||||||
@@ -423,9 +419,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
role: Value(role),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.id.equals(dbId)))
|
||||||
)..where((t) => t.id.equals(dbId)))
|
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return _toModel(row);
|
return _toModel(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
|
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
// Remove existing entry for same query (deduplication).
|
// Remove existing entry for same query (deduplication).
|
||||||
await (_db.delete(
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
_db.searchHistoryEntries,
|
..where((t) => t.query.equals(trimmed)))
|
||||||
)..where((t) => t.query.equals(trimmed)))
|
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
if (keepIds.isNotEmpty) {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
_db.searchHistoryEntries,
|
..where((t) => t.id.isNotIn(keepIds)))
|
||||||
)..where((t) => t.id.isNotIn(keepIds)))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
await _pruneExpired();
|
await _pruneExpired();
|
||||||
|
|
||||||
final keyIdHex = _hex(keyId);
|
final keyIdHex = _hex(keyId);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.shareKeys)
|
||||||
_db.shareKeys,
|
..where((t) => t.id.equals(keyIdHex)))
|
||||||
)..where((t) => t.id.equals(keyIdHex)))
|
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (row == null) return null;
|
if (row == null) return null;
|
||||||
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pruneExpired() async {
|
Future<void> _pruneExpired() async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.shareKeys)
|
||||||
_db.shareKeys,
|
..where(
|
||||||
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||||
|
))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<pref.UserPreferences> observePreferences() {
|
Stream<pref.UserPreferences> observePreferences() {
|
||||||
return (_db.select(
|
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
|
||||||
_db.userPreferences,
|
|
||||||
)..where((t) => t.id.equals(_rowId)))
|
|
||||||
.watchSingleOrNull()
|
.watchSingleOrNull()
|
||||||
.map(_rowToModel);
|
.map(_rowToModel);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-16
@@ -101,9 +101,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
final searchHistoryRepositoryProvider =
|
||||||
ref,
|
Provider<SearchHistoryRepository>((ref) {
|
||||||
) {
|
|
||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,10 +135,8 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
final isSyncingProvider =
|
||||||
ref,
|
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||||
accountId,
|
|
||||||
) {
|
|
||||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +185,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
final undoServiceProvider =
|
||||||
UndoService.new,
|
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||||
);
|
|
||||||
|
|
||||||
/// Loads email header + body and marks the email as seen.
|
/// Loads email header + body and marks the email as seen.
|
||||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||||
@@ -236,14 +232,12 @@ final accountConnectionStatusProvider =
|
|||||||
.testConnection(account, password);
|
.testConnection(account, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
final userPreferencesRepositoryProvider =
|
||||||
ref,
|
Provider<UserPreferencesRepository>((ref) {
|
||||||
) {
|
|
||||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
final userPreferencesProvider =
|
||||||
ref,
|
StreamProvider.autoDispose<UserPreferences>((ref) {
|
||||||
) {
|
|
||||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,10 +72,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
|
|
||||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||||
try {
|
try {
|
||||||
final launched = await launchUrl(
|
final launched =
|
||||||
url,
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -123,10 +121,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final launched = await launchUrl(
|
final launched =
|
||||||
url,
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -180,7 +176,9 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(_launchUrl(context, Uri.parse(href)));
|
unawaited(
|
||||||
|
_launchUrl(context, Uri.parse(href)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Step.done => const Center(
|
_Step.done => const Center(
|
||||||
child: Icon(Icons.check_circle, size: 64, color: Colors.green),
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Step.error => Center(
|
_Step.error => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -158,7 +158,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
for (final account in selected) {
|
for (final account in selected) {
|
||||||
final password = await repo.getPassword(account.id);
|
final password = await repo.getPassword(account.id);
|
||||||
payloads.add(
|
payloads.add(
|
||||||
AccountPayload(accountJson: account.toJson(), password: password),
|
AccountPayload(
|
||||||
|
accountJson: account.toJson(),
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +361,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Encrypted code copied to clipboard'),
|
content: Text(
|
||||||
|
'Encrypted code copied to clipboard',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: DefaultAssetBundle.of(
|
future:
|
||||||
context,
|
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||||
).loadString('assets/changelog.txt'),
|
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -194,7 +194,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
await OpenFilex.open(path);
|
await OpenFilex.open(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Failed to open file: $e'),
|
content: Text('Failed to open file: $e'),
|
||||||
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Select an account first'),
|
content: Text('Select an account first'),
|
||||||
@@ -251,7 +255,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Send failed: $e'),
|
content: Text('Send failed: $e'),
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ class CrashScreen extends StatelessWidget {
|
|||||||
builder: (context, snapshot) => Text(
|
builder: (context, snapshot) => Text(
|
||||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
context,
|
color: Colors.grey[600],
|
||||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,9 +54,8 @@ Future<Mailbox?> resolveMailboxByRole(
|
|||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final m in mailboxes.where(
|
for (final m
|
||||||
(m) => m.path != currentMailboxPath,
|
in mailboxes.where((m) => m.path != currentMailboxPath))
|
||||||
))
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.folder_outlined),
|
leading: const Icon(Icons.folder_outlined),
|
||||||
title: Text(m.name),
|
title: Text(m.name),
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_replyWithRecipientDialog(context, header, body));
|
unawaited(
|
||||||
|
_replyWithRecipientDialog(context, header, body),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -124,10 +126,22 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
const PopupMenuItem(
|
||||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
value: 'forward',
|
||||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
child: Text('Forward'),
|
||||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'move',
|
||||||
|
child: Text('Move to folder'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'snooze',
|
||||||
|
child: Text('Snooze'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'spam',
|
||||||
|
child: Text('Mark as spam'),
|
||||||
|
),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'mark_unread',
|
value: 'mark_unread',
|
||||||
child: Text('Mark as unread'),
|
child: Text('Mark as unread'),
|
||||||
@@ -141,7 +155,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
value: 'structure',
|
value: 'structure',
|
||||||
child: Text('Show Mail Structure'),
|
child: Text('Show Mail Structure'),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
const PopupMenuItem(
|
||||||
|
value: 'rfc',
|
||||||
|
child: Text('Show Raw Email'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'forward' && header != null) {
|
if (value == 'forward' && header != null) {
|
||||||
@@ -247,9 +264,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
.observeThreads(header.accountId, header.mailboxPath)
|
.observeThreads(header.accountId, header.mailboxPath)
|
||||||
.first;
|
.first;
|
||||||
|
|
||||||
final currentIndex = threads.indexWhere(
|
final currentIndex =
|
||||||
(t) => t.emailIds.contains(widget.emailId),
|
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
|
||||||
);
|
|
||||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||||
return threads[currentIndex + 1].latestEmailId;
|
return threads[currentIndex + 1].latestEmailId;
|
||||||
}
|
}
|
||||||
@@ -504,7 +520,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
extra: {
|
||||||
|
'prefillSubject': subject,
|
||||||
|
'prefillBody': quoted,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -606,9 +625,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
.fetchRawRfc822(widget.emailId);
|
.fetchRawRfc822(widget.emailId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,7 +792,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Structure not available. Try re-syncing the email.'),
|
content: Text(
|
||||||
|
'Structure not available. Try re-syncing the email.',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -882,8 +903,14 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
|||||||
SegmentedButton<_Placement>(
|
SegmentedButton<_Placement>(
|
||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(value: _Placement.to, label: Text('To')),
|
ButtonSegment(
|
||||||
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
value: _Placement.to,
|
||||||
|
label: Text('To'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _Placement.cc,
|
||||||
|
label: Text('Cc'),
|
||||||
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: _Placement.skip,
|
value: _Placement.skip,
|
||||||
label: Text('Skip'),
|
label: Text('Skip'),
|
||||||
|
|||||||
@@ -381,7 +381,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
return MaterialBanner(
|
return MaterialBanner(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
|
content: Text(
|
||||||
|
error,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.sync_problem,
|
Icons.sync_problem,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
@@ -395,8 +399,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () => context.push(
|
||||||
context.push('/accounts/${widget.accountId}/sync-log'),
|
'/accounts/${widget.accountId}/sync-log',
|
||||||
|
),
|
||||||
child: const Text('View log'),
|
child: const Text('View log'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import 'package:sharedinbox/core/utils/logger.dart';
|
|||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
|
|
||||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
final _searchHistoryProvider =
|
||||||
ref,
|
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||||
) async {
|
|
||||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
title: Text(
|
||||||
|
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -163,17 +163,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
FutureBuilder<EmailBody>(
|
FutureBuilder<EmailBody>(
|
||||||
future: _bodyFuture,
|
future: _bodyFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'Failed to load email: ${snapshot.error}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
.read(undoServiceProvider.notifier)
|
.read(undoServiceProvider.notifier)
|
||||||
.undo(actionId: action.id);
|
.undo(actionId: action.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Action undone.'),
|
content: Text('Action undone.'),
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
RadioListTile<MenuPosition>(
|
RadioListTile<MenuPosition>(
|
||||||
title: Text('Top'),
|
title: Text('Top'),
|
||||||
subtitle: Text('Show the back button in the top bar.'),
|
subtitle: Text(
|
||||||
|
'Show the back button in the top bar.',
|
||||||
|
),
|
||||||
value: MenuPosition.top,
|
value: MenuPosition.top,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -120,12 +122,16 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
RadioListTile<AfterMailViewAction>(
|
RadioListTile<AfterMailViewAction>(
|
||||||
title: Text('Next message (default)'),
|
title: Text('Next message (default)'),
|
||||||
subtitle: Text('Show the next message in the mailbox.'),
|
subtitle: Text(
|
||||||
|
'Show the next message in the mailbox.',
|
||||||
|
),
|
||||||
value: AfterMailViewAction.nextMessage,
|
value: AfterMailViewAction.nextMessage,
|
||||||
),
|
),
|
||||||
RadioListTile<AfterMailViewAction>(
|
RadioListTile<AfterMailViewAction>(
|
||||||
title: Text('Return to mailbox'),
|
title: Text('Return to mailbox'),
|
||||||
subtitle: Text('Return to the message list.'),
|
subtitle: Text(
|
||||||
|
'Return to the message list.',
|
||||||
|
),
|
||||||
value: AfterMailViewAction.showMailbox,
|
value: AfterMailViewAction.showMailbox,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ String buildAboutMarkdown({
|
|||||||
final osName = _capitalize(Platform.operatingSystem);
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
final locale = Localizations.localeOf(context).toString();
|
final locale = Localizations.localeOf(context).toString();
|
||||||
final textScale = MediaQuery.of(
|
final textScale =
|
||||||
context,
|
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
||||||
).textScaler.scale(1.0).toStringAsFixed(1);
|
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
|||||||
@@ -111,16 +111,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _measureHeight(String _) async {
|
Future<void> _measureHeight(String _) async {
|
||||||
try {
|
final result = await _controller!.runJavaScriptReturningResult(
|
||||||
final result = await _controller!.runJavaScriptReturningResult(
|
'document.documentElement.scrollHeight',
|
||||||
'document.documentElement.scrollHeight',
|
);
|
||||||
);
|
final h = double.tryParse(result.toString());
|
||||||
final h = double.tryParse(result.toString());
|
if (h != null && h > 0 && mounted) {
|
||||||
if (h != null && h > 0 && mounted) {
|
setState(() => _height = h);
|
||||||
setState(() => _height = h);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// WebView not ready yet; height stays at default
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +187,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final launched = await launchUrl(
|
final launched =
|
||||||
uri,
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && mounted) {
|
if (!launched && mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Could not open: $url')),
|
||||||
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ dependencies:
|
|||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
||||||
# Date formatting
|
# Date formatting
|
||||||
intl: ^0.20.2
|
intl: any
|
||||||
|
|
||||||
# File picking (compose attachments) and opening downloaded attachments
|
# File picking (compose attachments) and opening downloaded attachments
|
||||||
file_picker: ^12.0.0-beta.4
|
file_picker: ^12.0.0-beta.4
|
||||||
|
|||||||
Executable
+1135
File diff suppressed because it is too large
Load Diff
@@ -1,76 +1,102 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ -z "${SOPS_AGE_KEY:-}" ]; then
|
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||||
echo "Error: SOPS_AGE_KEY must be set."
|
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Decrypting secrets with SOPS..."
|
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||||
export SOPS_AGE_KEY="$SOPS_AGE_KEY"
|
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||||
SECRETS_JSON=$(mktemp)
|
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||||
trap "rm -f $SECRETS_JSON" EXIT
|
if [ "$host" == "$port" ]; then
|
||||||
|
port="8774"
|
||||||
|
fi
|
||||||
|
|
||||||
sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
MAX_PROBE_ATTEMPTS=5
|
||||||
|
PROBE_DELAY=30
|
||||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
echo "Probing $host:$port (attempt $attempt/$MAX_PROBE_ATTEMPTS)..."
|
||||||
|
if nc -zw 5 "$host" "$port" 2>/dev/null; then
|
||||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
echo "Found active server on $host:$port"
|
||||||
# can use them without referencing Forgejo secrets directly.
|
break
|
||||||
export_secret() {
|
|
||||||
local name="$1"
|
|
||||||
local value
|
|
||||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
|
||||||
# Use heredoc syntax for multiline-safe export
|
|
||||||
{
|
|
||||||
printf '%s<<__EOF__\n' "$name"
|
|
||||||
printf '%s\n' "$value"
|
|
||||||
printf '__EOF__\n'
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
fi
|
fi
|
||||||
printf '[secrets] exported %s (%d chars)\n' "$name" "${#value}"
|
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||||
}
|
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||||
|
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Dagger server not responding, waiting ${PROBE_DELAY}s before retry..."
|
||||||
|
sleep $PROBE_DELAY
|
||||||
|
done
|
||||||
|
|
||||||
export_secret "SSH_PRIVATE_KEY"
|
# 2a. Try plain TCP connection first (works when server is a plain TCP proxy, no TLS)
|
||||||
export_secret "SSH_KNOWN_HOSTS"
|
echo "Trying plain TCP Dagger connection at tcp://$host:$port..."
|
||||||
export_secret "SSH_USER"
|
if _DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||||
export_secret "SSH_HOST"
|
_EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port" \
|
||||||
export_secret "WEBSITE_SSH_HOST"
|
timeout 8 dagger version >/dev/null 2>&1; then
|
||||||
export_secret "PLAY_STORE_CONFIG_JSON"
|
echo "Plain TCP Dagger connection succeeded — no TLS stunnel needed."
|
||||||
export_secret "ANDROID_KEYSTORE_BASE64"
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
export_secret "ANDROID_KEYSTORE_PASSWORD"
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||||
export_secret "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
echo "_DAGGER_RUNNER_HOST=tcp://$host:$port" >> "$GITHUB_ENV"
|
||||||
export_secret "RENOVATE_FORGEJO_TOKEN"
|
else
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||||
# Setup SSH directory and keys
|
export _DAGGER_RUNNER_HOST="tcp://$host:$port"
|
||||||
mkdir -p ~/.ssh
|
echo "Dagger configured at tcp://$host:$port (plain TCP)"
|
||||||
chmod 700 ~/.ssh
|
fi
|
||||||
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
exit 0
|
||||||
chmod 600 ~/.ssh/dagger_key
|
|
||||||
|
|
||||||
# Add remote host to known_hosts
|
|
||||||
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
# Create a background SSH tunnel to the Dagger engine.
|
|
||||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
|
||||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
|
||||||
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
|
||||||
|
|
||||||
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
|
||||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
|
||||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
|
||||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://localhost:8080" >> "$GITHUB_ENV"
|
|
||||||
fi
|
fi
|
||||||
|
echo "Plain TCP connection not available; trying TLS stunnel..."
|
||||||
|
|
||||||
# Verify the connection
|
# 2b. Setup TLS credentials (passed as env vars from secrets)
|
||||||
echo "Verifying connection to Dagger engine via SSH tunnel..."
|
mkdir -p /tmp/dagger-tls
|
||||||
# Use a simple command that doesn't require complex GraphQL operations.
|
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||||
if ! timeout 45 dagger core --help >/dev/null 2>&1 ; then
|
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||||
echo "Error: Dagger engine unreachable via tunnel at localhost:8080"
|
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||||
# Debug
|
chmod 600 /tmp/dagger-tls/client.key
|
||||||
ps aux | grep ssh
|
|
||||||
|
# 3. Configure and start stunnel
|
||||||
|
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||||
|
cat << EOF > "$STUNNEL_CONF"
|
||||||
|
client = yes
|
||||||
|
foreground = yes
|
||||||
|
pid = /tmp/stunnel.pid
|
||||||
|
debug = warning
|
||||||
|
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||||
|
socket = r:SO_KEEPALIVE=1
|
||||||
|
socket = r:TCP_KEEPIDLE=10
|
||||||
|
socket = r:TCP_KEEPINTVL=5
|
||||||
|
socket = r:TCP_KEEPCNT=3
|
||||||
|
|
||||||
|
[dagger]
|
||||||
|
accept = 127.0.0.1:1774
|
||||||
|
connect = $host:$port
|
||||||
|
CAfile = /tmp/dagger-tls/ca.crt
|
||||||
|
cert = /tmp/dagger-tls/client.crt
|
||||||
|
key = /tmp/dagger-tls/client.key
|
||||||
|
verifyChain = yes
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start stunnel in the background
|
||||||
|
stunnel "$STUNNEL_CONF" &
|
||||||
|
TUNNEL_PID=$!
|
||||||
|
|
||||||
|
# Give it a moment to establish
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||||
|
echo "Error: stunnel failed to start"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Dagger connection verified successfully."
|
|
||||||
|
# 4. Export environment for subsequent CI steps
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||||
|
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||||
|
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||||
|
else
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||||
|
fi
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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');
|
throw const SocketException('fake — no real IMAP server in tests');
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||||
'AccountSyncManager schedules IMAP sync for multiple accounts',
|
() async {
|
||||||
() async {
|
final accounts = _FakeAccounts('pw');
|
||||||
final accounts = _FakeAccounts('pw');
|
final mailboxes = _FakeMailboxes();
|
||||||
final mailboxes = _FakeMailboxes();
|
final emails = _FakeEmails();
|
||||||
final emails = _FakeEmails();
|
final logs = _FakeLogs();
|
||||||
final logs = _FakeLogs();
|
|
||||||
|
|
||||||
final manager = AccountSyncManager(
|
final manager = AccountSyncManager(
|
||||||
accounts,
|
accounts,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
emails,
|
emails,
|
||||||
syncLog: logs,
|
syncLog: logs,
|
||||||
imapConnect: _fakeImapConnect,
|
imapConnect: _fakeImapConnect,
|
||||||
);
|
);
|
||||||
|
|
||||||
final a1 = _account('1');
|
final a1 = _account('1');
|
||||||
final a2 = _account('2');
|
final a2 = _account('2');
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
accounts.push([a1, a2]);
|
accounts.push([a1, a2]);
|
||||||
|
|
||||||
// Allow some time for listeners to fire.
|
// Allow some time for listeners to fire.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
manager.dispose();
|
manager.dispose();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||||
'AccountSyncManager schedules JMAP sync for multiple accounts',
|
() async {
|
||||||
() async {
|
final accounts = _FakeAccounts('pw');
|
||||||
final accounts = _FakeAccounts('pw');
|
final mailboxes = _FakeMailboxes();
|
||||||
final mailboxes = _FakeMailboxes();
|
final emails = _FakeEmails();
|
||||||
final emails = _FakeEmails();
|
final logs = _FakeLogs();
|
||||||
final logs = _FakeLogs();
|
|
||||||
|
|
||||||
final manager = AccountSyncManager(
|
final manager = AccountSyncManager(
|
||||||
accounts,
|
accounts,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
emails,
|
emails,
|
||||||
syncLog: logs,
|
syncLog: logs,
|
||||||
);
|
);
|
||||||
|
|
||||||
final a1 = _jmapAccount('1');
|
final a1 = _jmapAccount('1');
|
||||||
final a2 = _jmapAccount('2');
|
final a2 = _jmapAccount('2');
|
||||||
|
|
||||||
manager.start();
|
manager.start();
|
||||||
accounts.push([a1, a2]);
|
accounts.push([a1, a2]);
|
||||||
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
manager.dispose();
|
manager.dispose();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Account _account(String id) => Account(
|
Account _account(String id) => Account(
|
||||||
@@ -175,7 +171,11 @@ class _FakeEmails implements EmailRepository {
|
|||||||
final syncCounts = <String, int>{};
|
final syncCounts = <String, int>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
Stream<List<Email>> observeEmails(
|
||||||
|
String a,
|
||||||
|
String m, {
|
||||||
|
int limit = 50,
|
||||||
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -566,61 +566,59 @@ void main() {
|
|||||||
expect(pending.first.changeType, 'delete');
|
expect(pending.first.changeType, 'delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('downloadAttachment fetches binary attachment bytes from IMAP',
|
||||||
'downloadAttachment fetches binary attachment bytes from IMAP',
|
() async {
|
||||||
() async {
|
final attachmentBytes = Uint8List.fromList(
|
||||||
final attachmentBytes = Uint8List.fromList(
|
List.generate(32, (i) => i + 1),
|
||||||
List.generate(32, (i) => i + 1),
|
);
|
||||||
|
const attachmentName = 'hello.bin';
|
||||||
|
const attachmentMime = 'application/octet-stream';
|
||||||
|
|
||||||
|
// Build a multipart email with a binary attachment and append it.
|
||||||
|
final client = await _imapConnect(
|
||||||
|
host: imapHost,
|
||||||
|
port: imapPort,
|
||||||
|
user: userEmail,
|
||||||
|
pass: userPass,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final builder = MessageBuilder()
|
||||||
|
..from = [MailAddress('Alice', userEmail)]
|
||||||
|
..to = [MailAddress('Alice', userEmail)]
|
||||||
|
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||||
|
..text = 'See attachment.';
|
||||||
|
builder.addBinary(
|
||||||
|
attachmentBytes,
|
||||||
|
MediaType.fromText(attachmentMime),
|
||||||
|
filename: attachmentName,
|
||||||
);
|
);
|
||||||
const attachmentName = 'hello.bin';
|
await client.appendMessage(
|
||||||
const attachmentMime = 'application/octet-stream';
|
builder.buildMimeMessage(),
|
||||||
|
targetMailboxPath: 'INBOX',
|
||||||
// Build a multipart email with a binary attachment and append it.
|
|
||||||
final client = await _imapConnect(
|
|
||||||
host: imapHost,
|
|
||||||
port: imapPort,
|
|
||||||
user: userEmail,
|
|
||||||
pass: userPass,
|
|
||||||
);
|
);
|
||||||
try {
|
} finally {
|
||||||
final builder = MessageBuilder()
|
await client.logout();
|
||||||
..from = [MailAddress('Alice', userEmail)]
|
}
|
||||||
..to = [MailAddress('Alice', userEmail)]
|
|
||||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
|
||||||
..text = 'See attachment.';
|
|
||||||
builder.addBinary(
|
|
||||||
attachmentBytes,
|
|
||||||
MediaType.fromText(attachmentMime),
|
|
||||||
filename: attachmentName,
|
|
||||||
);
|
|
||||||
await client.appendMessage(
|
|
||||||
builder.buildMimeMessage(),
|
|
||||||
targetMailboxPath: 'INBOX',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
await r.emails.syncEmails('test', 'INBOX');
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||||
expect(emails, hasLength(1));
|
expect(emails, hasLength(1));
|
||||||
expect(emails.first.hasAttachment, isTrue);
|
expect(emails.first.hasAttachment, isTrue);
|
||||||
|
|
||||||
final body = await r.emails.getEmailBody(emails.first.id);
|
final body = await r.emails.getEmailBody(emails.first.id);
|
||||||
expect(body.attachments, hasLength(1));
|
expect(body.attachments, hasLength(1));
|
||||||
expect(body.attachments.first.filename, attachmentName);
|
expect(body.attachments.first.filename, attachmentName);
|
||||||
expect(body.attachments.first.contentType, attachmentMime);
|
expect(body.attachments.first.contentType, attachmentMime);
|
||||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||||
|
|
||||||
final path = await r.emails.downloadAttachment(
|
final path = await r.emails.downloadAttachment(
|
||||||
emails.first.id,
|
emails.first.id,
|
||||||
body.attachments.first,
|
body.attachments.first,
|
||||||
);
|
);
|
||||||
final downloaded = await File(path).readAsBytes();
|
final downloaded = await File(path).readAsBytes();
|
||||||
expect(downloaded, equals(attachmentBytes));
|
expect(downloaded, equals(attachmentBytes));
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,15 +73,13 @@ abstract class AccountRepositoryContract {
|
|||||||
expect(await repo.getPassword(_a.id), 'new');
|
expect(await repo.getPassword(_a.id), 'new');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('removeAccount makes account disappear from observeAccounts',
|
||||||
'removeAccount makes account disappear from observeAccounts',
|
() async {
|
||||||
() async {
|
final repo = makeRepo();
|
||||||
final repo = makeRepo();
|
await repo.addAccount(_a, 'pw');
|
||||||
await repo.addAccount(_a, 'pw');
|
await repo.removeAccount(_a.id);
|
||||||
await repo.removeAccount(_a.id);
|
expect(await repo.observeAccounts().first, isEmpty);
|
||||||
expect(await repo.observeAccounts().first, isEmpty);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('getAccount returns null after removeAccount', () async {
|
test('getAccount returns null after removeAccount', () async {
|
||||||
final repo = makeRepo();
|
final repo = makeRepo();
|
||||||
|
|||||||
@@ -37,41 +37,44 @@ void main() {
|
|||||||
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
// MissingPluginException (channel unavailable on the device), the IMAP sync
|
||||||
// loop must stop permanently instead of retrying indefinitely with backoff.
|
// loop must stop permanently instead of retrying indefinitely with backoff.
|
||||||
test(
|
test(
|
||||||
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
'MissingPluginException from secure storage stops IMAP sync loop permanently',
|
||||||
() async {
|
() async {
|
||||||
final syncLog = FakeSyncLogRepository();
|
final syncLog = FakeSyncLogRepository();
|
||||||
|
|
||||||
final m = AccountSyncManager(
|
final m = AccountSyncManager(
|
||||||
_AccountRepositoryWithMissingPlugin(),
|
_AccountRepositoryWithMissingPlugin(),
|
||||||
FakeMailboxRepositoryWithInbox(),
|
FakeMailboxRepositoryWithInbox(),
|
||||||
FakeEmailRepository(),
|
FakeEmailRepository(),
|
||||||
syncLog: syncLog,
|
syncLog: syncLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
m.start();
|
m.start();
|
||||||
|
|
||||||
// Allow the first sync cycle to run and fail.
|
// Allow the first sync cycle to run and fail.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(syncLog.logs, hasLength(1));
|
expect(syncLog.logs, hasLength(1));
|
||||||
expect(syncLog.logs.first.success, isFalse);
|
expect(syncLog.logs.first.success, isFalse);
|
||||||
|
|
||||||
// Kicking the loop should have no effect once it has stopped permanently.
|
// Kicking the loop should have no effect once it has stopped permanently.
|
||||||
m.syncNow('1');
|
m.syncNow('1');
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
// Before the fix: kick triggers a retry → 2 log entries.
|
// Before the fix: kick triggers a retry → 2 log entries.
|
||||||
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
// After the fix: loop is permanently stopped → still exactly 1 entry.
|
||||||
expect(syncLog.logs, hasLength(1));
|
expect(syncLog.logs, hasLength(1));
|
||||||
|
|
||||||
m.dispose();
|
m.dispose();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeEmailRepository implements EmailRepository {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
Stream<List<Email>> observeEmails(
|
||||||
|
String a,
|
||||||
|
String m, {
|
||||||
|
int limit = 50,
|
||||||
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
Stream<List<EmailThread>> observeThreads(
|
Stream<List<EmailThread>> observeThreads(
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ void main() {
|
|||||||
// startup, throwing PlatformException(channel-error, ...).
|
// startup, throwing PlatformException(channel-error, ...).
|
||||||
// registerBackgroundSync() must absorb the failure and let the app continue.
|
// registerBackgroundSync() must absorb the failure and let the app continue.
|
||||||
test(
|
test(
|
||||||
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
'registerBackgroundSync completes without throwing when plugin is unavailable',
|
||||||
() async {
|
() async {
|
||||||
// In the unit-test environment the native WorkManager plugin is not
|
// In the unit-test environment the native WorkManager plugin is not
|
||||||
// registered, so Workmanager().initialize() throws a PlatformException or
|
// registered, so Workmanager().initialize() throws a PlatformException or
|
||||||
// MissingPluginException. The fix catches it. This test fails before the
|
// MissingPluginException. The fix catches it. This test fails before the
|
||||||
// fix (exception propagates) and passes after it (exception is swallowed).
|
// fix (exception propagates) and passes after it (exception is swallowed).
|
||||||
await expectLater(registerBackgroundSync(), completes);
|
await expectLater(registerBackgroundSync(), completes);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,8 @@ void main() {
|
|||||||
final result = injectInlineImages(html, msg);
|
final result = injectInlineImages(html, msg);
|
||||||
|
|
||||||
// Extract base64 payload from the data URI.
|
// Extract base64 payload from the data URI.
|
||||||
final match = RegExp(
|
final match =
|
||||||
r'data:image/png;base64,([A-Za-z0-9+/=]+)',
|
RegExp(r'data:image/png;base64,([A-Za-z0-9+/=]+)').firstMatch(result);
|
||||||
).firstMatch(result);
|
|
||||||
expect(match, isNotNull);
|
expect(match, isNotNull);
|
||||||
final decoded = base64.decode(match!.group(1)!);
|
final decoded = base64.decode(match!.group(1)!);
|
||||||
expect(decoded.length, greaterThan(0));
|
expect(decoded.length, greaterThan(0));
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ abstract class EmailRepositoryContract {
|
|||||||
void run() {
|
void run() {
|
||||||
test('observeEmails starts empty', () async {
|
test('observeEmails starts empty', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
expect(await repo.observeEmails(_account.id, 'INBOX').first, isEmpty);
|
expect(
|
||||||
|
await repo.observeEmails(_account.id, 'INBOX').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observeEmails emits inserted email', () async {
|
test('observeEmails emits inserted email', () async {
|
||||||
@@ -58,7 +61,10 @@ abstract class EmailRepositoryContract {
|
|||||||
test('observeEmails only returns emails for the given mailbox', () async {
|
test('observeEmails only returns emails for the given mailbox', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||||
expect(await repo.observeEmails(_account.id, 'Sent').first, isEmpty);
|
expect(
|
||||||
|
await repo.observeEmails(_account.id, 'Sent').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observeEmails orders by receivedAt descending', () async {
|
test('observeEmails orders by receivedAt descending', () async {
|
||||||
@@ -110,7 +116,11 @@ abstract class EmailRepositoryContract {
|
|||||||
|
|
||||||
test('setFlag flagged updates isFlagged', () async {
|
test('setFlag flagged updates isFlagged', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
await insertEmail(repo, id: 'er-acc:11', mailboxPath: 'INBOX');
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:11',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
await repo.setFlag('er-acc:11', flagged: true);
|
await repo.setFlag('er-acc:11', flagged: true);
|
||||||
final email = await repo.getEmail('er-acc:11');
|
final email = await repo.getEmail('er-acc:11');
|
||||||
expect(email!.isFlagged, isTrue);
|
expect(email!.isFlagged, isTrue);
|
||||||
@@ -147,7 +157,10 @@ abstract class EmailRepositoryContract {
|
|||||||
|
|
||||||
test('observeThreads starts empty', () async {
|
test('observeThreads starts empty', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
expect(await repo.observeThreads(_account.id, 'INBOX').first, isEmpty);
|
expect(
|
||||||
|
await repo.observeThreads(_account.id, 'INBOX').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,49 +453,47 @@ void main() {
|
|||||||
expect(results.first.subject, 'foobar baz');
|
expect(results.first.subject, 'foobar baz');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('searchAddresses returns results sorted by most recently used',
|
||||||
'searchAddresses returns results sorted by most recently used',
|
() async {
|
||||||
() async {
|
final r = _makeRepos();
|
||||||
final r = _makeRepos();
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
|
||||||
|
|
||||||
final older = DateTime(2024);
|
final older = DateTime(2024);
|
||||||
final newer = DateTime(2024, 6);
|
final newer = DateTime(2024, 6);
|
||||||
|
|
||||||
// Two emails — older one has alice@, newer one has bob@.
|
// Two emails — older one has alice@, newer one has bob@.
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'acc-1:old',
|
id: 'acc-1:old',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 1,
|
uid: 1,
|
||||||
receivedAt: older,
|
receivedAt: older,
|
||||||
toAddresses: const Value(
|
toAddresses: const Value(
|
||||||
'[{"name":"Alice","email":"alice@example.com"}]',
|
'[{"name":"Alice","email":"alice@example.com"}]',
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
await r.db.into(r.db.emails).insert(
|
);
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:new',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:new',
|
||||||
mailboxPath: 'Sent',
|
accountId: 'acc-1',
|
||||||
uid: 2,
|
mailboxPath: 'Sent',
|
||||||
receivedAt: newer,
|
uid: 2,
|
||||||
toAddresses: const Value(
|
receivedAt: newer,
|
||||||
'[{"name":"Bob","email":"bob@example.com"}]',
|
toAddresses: const Value(
|
||||||
),
|
'[{"name":"Bob","email":"bob@example.com"}]',
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Query matching both; newer (bob) should come first.
|
// Query matching both; newer (bob) should come first.
|
||||||
final results = await r.emails.searchAddresses(null, 'example');
|
final results = await r.emails.searchAddresses(null, 'example');
|
||||||
expect(results.map((a) => a.email).toList(), [
|
expect(
|
||||||
'bob@example.com',
|
results.map((a) => a.email).toList(),
|
||||||
'alice@example.com',
|
['bob@example.com', 'alice@example.com'],
|
||||||
]);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// ── IMAP method tests ────────────────────────────────────────────────────
|
// ── IMAP method tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -699,47 +697,47 @@ void main() {
|
|||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('snooze flush selects src mailbox and moves email to Snoozed',
|
||||||
'snooze flush selects src mailbox and moves email to Snoozed',
|
() async {
|
||||||
() async {
|
final spy = SnoozeSpyImapClient();
|
||||||
final spy = SnoozeSpyImapClient();
|
final r = _makeRepos(
|
||||||
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
|
imapConnect: (_, __, ___) async => spy,
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
);
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
EmailsCompanion.insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
id: 'acc-1:5',
|
EmailsCompanion.insert(
|
||||||
accountId: 'acc-1',
|
id: 'acc-1:5',
|
||||||
mailboxPath: 'Snoozed',
|
accountId: 'acc-1',
|
||||||
uid: 5,
|
mailboxPath: 'Snoozed',
|
||||||
receivedAt: DateTime(2024),
|
uid: 5,
|
||||||
),
|
receivedAt: DateTime(2024),
|
||||||
);
|
),
|
||||||
await r.db.into(r.db.pendingChanges).insert(
|
);
|
||||||
PendingChangesCompanion.insert(
|
await r.db.into(r.db.pendingChanges).insert(
|
||||||
accountId: 'acc-1',
|
PendingChangesCompanion.insert(
|
||||||
resourceType: 'Email',
|
accountId: 'acc-1',
|
||||||
resourceId: 'acc-1:5',
|
resourceType: 'Email',
|
||||||
changeType: 'snooze',
|
resourceId: 'acc-1:5',
|
||||||
payload: jsonEncode({
|
changeType: 'snooze',
|
||||||
'uid': 5,
|
payload: jsonEncode({
|
||||||
'src': 'INBOX',
|
'uid': 5,
|
||||||
'dest': 'Snoozed',
|
'src': 'INBOX',
|
||||||
'until': '2026-05-10T15:00:00.000',
|
'dest': 'Snoozed',
|
||||||
}),
|
'until': '2026-05-10T15:00:00.000',
|
||||||
createdAt: DateTime.now(),
|
}),
|
||||||
),
|
createdAt: DateTime.now(),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('acc-1', 'pw');
|
await r.emails.flushPendingChanges('acc-1', 'pw');
|
||||||
|
|
||||||
// Change successfully applied — removed from queue.
|
// Change successfully applied — removed from queue.
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
// Source mailbox extracted from 'src', not 'mailboxPath'.
|
// Source mailbox extracted from 'src', not 'mailboxPath'.
|
||||||
expect(spy.selectedMailbox, 'INBOX');
|
expect(spy.selectedMailbox, 'INBOX');
|
||||||
expect(spy.createdMailbox, 'Snoozed');
|
expect(spy.createdMailbox, 'Snoozed');
|
||||||
expect(spy.movedToMailbox, 'Snoozed');
|
expect(spy.movedToMailbox, 'Snoozed');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Snooze', () {
|
group('Snooze', () {
|
||||||
@@ -1642,123 +1640,119 @@ void main() {
|
|||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
|
||||||
'snooze creates Snoozed folder via Mailbox/set when dest is Snoozed',
|
() async {
|
||||||
() async {
|
final List<Map<String, dynamic>> capturedBodies = [];
|
||||||
final List<Map<String, dynamic>> capturedBodies = [];
|
final client = MockClient((req) async {
|
||||||
final client = MockClient((req) async {
|
if (req.url.path.contains('well-known')) {
|
||||||
if (req.url.path.contains('well-known')) {
|
return http.Response(
|
||||||
return http.Response(
|
jsonEncode({
|
||||||
jsonEncode({
|
'apiUrl': 'https://jmap.example.com/api/',
|
||||||
'apiUrl': 'https://jmap.example.com/api/',
|
'accounts': {
|
||||||
'accounts': {
|
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
||||||
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
},
|
||||||
},
|
'primaryAccounts': {
|
||||||
'primaryAccounts': {
|
'urn:ietf:params:jmap:core': 'acct1',
|
||||||
'urn:ietf:params:jmap:core': 'acct1',
|
'urn:ietf:params:jmap:mail': 'acct1',
|
||||||
'urn:ietf:params:jmap:mail': 'acct1',
|
},
|
||||||
},
|
'capabilities': {},
|
||||||
'capabilities': {},
|
'username': 'alice@example.com',
|
||||||
'username': 'alice@example.com',
|
'state': 'sess1',
|
||||||
'state': 'sess1',
|
}),
|
||||||
}),
|
200,
|
||||||
200,
|
);
|
||||||
);
|
}
|
||||||
}
|
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
capturedBodies.add(body);
|
||||||
capturedBodies.add(body);
|
final calls = body['methodCalls'] as List;
|
||||||
final calls = body['methodCalls'] as List;
|
final methodName = (calls.first as List)[0] as String;
|
||||||
final methodName = (calls.first as List)[0] as String;
|
if (methodName == 'Mailbox/set') {
|
||||||
if (methodName == 'Mailbox/set') {
|
|
||||||
return http.Response(
|
|
||||||
jsonEncode({
|
|
||||||
'sessionState': 's1',
|
|
||||||
'methodResponses': [
|
|
||||||
[
|
|
||||||
'Mailbox/set',
|
|
||||||
{
|
|
||||||
'accountId': 'acct1',
|
|
||||||
'created': {
|
|
||||||
'new-snoozed': {'id': 'mbx-snoozed'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'sessionState': 's1',
|
'sessionState': 's1',
|
||||||
'methodResponses': [
|
'methodResponses': [
|
||||||
[
|
[
|
||||||
'Email/set',
|
'Mailbox/set',
|
||||||
{'accountId': 'acct1', 'updated': {}},
|
{
|
||||||
|
'accountId': 'acct1',
|
||||||
|
'created': {
|
||||||
|
'new-snoozed': {'id': 'mbx-snoozed'},
|
||||||
|
},
|
||||||
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
return http.Response(
|
||||||
final r = _makeRepos(httpClient: client);
|
jsonEncode({
|
||||||
await seedChange(
|
'sessionState': 's1',
|
||||||
r.db,
|
'methodResponses': [
|
||||||
r.accounts,
|
[
|
||||||
changeType: 'snooze',
|
'Email/set',
|
||||||
payload: jsonEncode({
|
{'accountId': 'acct1', 'updated': {}},
|
||||||
'uid': 0,
|
'0',
|
||||||
'src': 'mbx-inbox',
|
],
|
||||||
'dest': 'Snoozed',
|
],
|
||||||
'until': '2026-05-10T15:00:00.000',
|
|
||||||
}),
|
}),
|
||||||
|
200,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
final r = _makeRepos(httpClient: client);
|
||||||
|
await seedChange(
|
||||||
|
r.db,
|
||||||
|
r.accounts,
|
||||||
|
changeType: 'snooze',
|
||||||
|
payload: jsonEncode({
|
||||||
|
'uid': 0,
|
||||||
|
'src': 'mbx-inbox',
|
||||||
|
'dest': 'Snoozed',
|
||||||
|
'until': '2026-05-10T15:00:00.000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Change successfully applied — removed from queue.
|
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
|
||||||
|
|
||||||
// First API call should be Mailbox/set to create the Snoozed folder.
|
// Change successfully applied — removed from queue.
|
||||||
expect(capturedBodies, hasLength(2));
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
final firstCall =
|
|
||||||
((capturedBodies.first['methodCalls'] as List).first as List)[0];
|
|
||||||
expect(firstCall, 'Mailbox/set');
|
|
||||||
|
|
||||||
// Second call should be Email/set using the newly created mailbox ID.
|
// First API call should be Mailbox/set to create the Snoozed folder.
|
||||||
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
expect(capturedBodies, hasLength(2));
|
||||||
as List)[1] as Map<String, dynamic>;
|
final firstCall =
|
||||||
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
((capturedBodies.first['methodCalls'] as List).first as List)[0];
|
||||||
as Map<String, dynamic>;
|
expect(firstCall, 'Mailbox/set');
|
||||||
expect(update['mailboxIds/mbx-snoozed'], true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
// Second call should be Email/set using the newly created mailbox ID.
|
||||||
'snooze uses existing mailbox ID when dest is already a JMAP ID',
|
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
||||||
() async {
|
as List)[1] as Map<String, dynamic>;
|
||||||
final r = _makeRepos(httpClient: mockFlush(200));
|
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||||
await seedChange(
|
as Map<String, dynamic>;
|
||||||
r.db,
|
expect(update['mailboxIds/mbx-snoozed'], true);
|
||||||
r.accounts,
|
});
|
||||||
changeType: 'snooze',
|
|
||||||
payload: jsonEncode({
|
|
||||||
'uid': 0,
|
|
||||||
'src': 'mbx-inbox',
|
|
||||||
'dest': 'mbx-snoozed',
|
|
||||||
'until': '2026-05-10T15:00:00.000',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
test('snooze uses existing mailbox ID when dest is already a JMAP ID',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos(httpClient: mockFlush(200));
|
||||||
|
await seedChange(
|
||||||
|
r.db,
|
||||||
|
r.accounts,
|
||||||
|
changeType: 'snooze',
|
||||||
|
payload: jsonEncode({
|
||||||
|
'uid': 0,
|
||||||
|
'src': 'mbx-inbox',
|
||||||
|
'dest': 'mbx-snoozed',
|
||||||
|
'until': '2026-05-10T15:00:00.000',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Change applied without needing Mailbox/set (dest was already a valid ID).
|
await r.emails.flushPendingChanges('jmap-1', 'pw');
|
||||||
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
|
||||||
},
|
// Change applied without needing Mailbox/set (dest was already a valid ID).
|
||||||
);
|
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('JMAP syncEmails body caching', () {
|
group('JMAP syncEmails body caching', () {
|
||||||
@@ -2288,42 +2282,41 @@ void main() {
|
|||||||
|
|
||||||
group('concurrent moves', () {
|
group('concurrent moves', () {
|
||||||
test(
|
test(
|
||||||
'two simultaneous moves enqueue two changes and leave email in last destination',
|
'two simultaneous moves enqueue two changes and leave email in last destination',
|
||||||
() async {
|
() async {
|
||||||
final r = _makeRepos();
|
final r = _makeRepos();
|
||||||
await r.accounts.addAccount(_account, 'pw');
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
await r.db.into(r.db.emails).insert(
|
await r.db.into(r.db.emails).insert(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: 'acc-1:5',
|
id: 'acc-1:5',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
mailboxPath: 'INBOX',
|
mailboxPath: 'INBOX',
|
||||||
uid: 5,
|
uid: 5,
|
||||||
receivedAt: DateTime(2024),
|
receivedAt: DateTime(2024),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fire both moves without awaiting to exercise concurrent enqueue logic.
|
// Fire both moves without awaiting to exercise concurrent enqueue logic.
|
||||||
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
|
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
|
||||||
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
|
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
|
||||||
await Future.wait([f1, f2]);
|
await Future.wait([f1, f2]);
|
||||||
|
|
||||||
final changes = await r.db.select(r.db.pendingChanges).get();
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
expect(changes, hasLength(2));
|
expect(changes, hasLength(2));
|
||||||
expect(changes.map((c) => c.changeType), everyElement('move'));
|
expect(changes.map((c) => c.changeType), everyElement('move'));
|
||||||
|
|
||||||
final destinations =
|
final destinations =
|
||||||
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
||||||
expect(destinations, containsAll(['Archive', 'Trash']));
|
expect(destinations, containsAll(['Archive', 'Trash']));
|
||||||
|
|
||||||
final email = await r.emails.getEmail('acc-1:5');
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
expect(
|
expect(
|
||||||
email!.mailboxPath,
|
email!.mailboxPath,
|
||||||
anyOf('Archive', 'Trash'),
|
anyOf('Archive', 'Trash'),
|
||||||
reason:
|
reason:
|
||||||
'email must be optimistically moved to one of the two destinations',
|
'email must be optimistically moved to one of the two destinations',
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('IMAP SMTP auth failure', () {
|
group('IMAP SMTP auth failure', () {
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ abstract class MailboxRepositoryContract {
|
|||||||
|
|
||||||
test('findMailboxByRole returns null when no match', () async {
|
test('findMailboxByRole returns null when no match', () async {
|
||||||
final repo = await makeRepo();
|
final repo = await makeRepo();
|
||||||
expect(await repo.findMailboxByRole(_account.id, 'archive'), isNull);
|
expect(
|
||||||
|
await repo.findMailboxByRole(_account.id, 'archive'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('findMailboxByRole returns the matching mailbox', () async {
|
test('findMailboxByRole returns the matching mailbox', () async {
|
||||||
|
|||||||
@@ -486,11 +486,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||||
|
|
||||||
final result = await r.mailboxes.createMailboxWithRole(
|
final result = await r.mailboxes
|
||||||
'jmap-1',
|
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
|
||||||
'Archive',
|
|
||||||
'archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.name, 'Archive');
|
expect(result.name, 'Archive');
|
||||||
expect(result.role, 'archive');
|
expect(result.role, 'archive');
|
||||||
@@ -501,80 +498,81 @@ void main() {
|
|||||||
expect(found!.name, 'Archive');
|
expect(found!.name, 'Archive');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('JMAP: throws when server returns no created ID', () async {
|
test(
|
||||||
final r = _makeRepos(
|
'JMAP: throws when server returns no created ID',
|
||||||
httpClient: _mockJmap(
|
() async {
|
||||||
apiResponses: [
|
final r = _makeRepos(
|
||||||
{
|
httpClient: _mockJmap(
|
||||||
'sessionState': 'sess1',
|
apiResponses: [
|
||||||
'methodResponses': [
|
{
|
||||||
[
|
'sessionState': 'sess1',
|
||||||
'Mailbox/set',
|
'methodResponses': [
|
||||||
{
|
[
|
||||||
'accountId': 'acct1',
|
'Mailbox/set',
|
||||||
'created': null,
|
{
|
||||||
'notCreated': {
|
'accountId': 'acct1',
|
||||||
'new-mailbox': {'type': 'serverFail'},
|
'created': null,
|
||||||
|
'notCreated': {
|
||||||
|
'new-mailbox': {'type': 'serverFail'},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
'0',
|
||||||
'0',
|
],
|
||||||
],
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
|
||||||
throwsA(isA<Exception>()),
|
throwsA(isA<Exception>()),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('syncMailboxes IMAP preserves manually-set role', () {
|
group('syncMailboxes IMAP preserves manually-set role', () {
|
||||||
test(
|
test('existing role is kept when server returns no special-use flag',
|
||||||
'existing role is kept when server returns no special-use flag',
|
() async {
|
||||||
() async {
|
final spy = SnoozeSpyImapClient();
|
||||||
final spy = SnoozeSpyImapClient();
|
// Make listMailboxes return a plain folder without \Archive.
|
||||||
// Make listMailboxes return a plain folder without \Archive.
|
final db = openTestDatabase();
|
||||||
final db = openTestDatabase();
|
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
|
||||||
|
|
||||||
// Override listMailboxes to return one plain folder.
|
// Override listMailboxes to return one plain folder.
|
||||||
final fakeClient = _PlainArchiveImapClient();
|
final fakeClient = _PlainArchiveImapClient();
|
||||||
final mailboxes = MailboxRepositoryImpl(
|
final mailboxes = MailboxRepositoryImpl(
|
||||||
db,
|
db,
|
||||||
accounts,
|
accounts,
|
||||||
imapConnect: (_, __, ___) async => fakeClient,
|
imapConnect: (_, __, ___) async => fakeClient,
|
||||||
);
|
);
|
||||||
await accounts.addAccount(_account, 'pw');
|
await accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||||
await db.into(db.mailboxes).insert(
|
await db.into(db.mailboxes).insert(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: 'acc-1:Archive',
|
id: 'acc-1:Archive',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
path: 'Archive',
|
path: 'Archive',
|
||||||
name: 'Archive',
|
name: 'Archive',
|
||||||
role: const Value('archive'),
|
role: const Value('archive'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await mailboxes.syncMailboxes('acc-1');
|
await mailboxes.syncMailboxes('acc-1');
|
||||||
|
|
||||||
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
|
||||||
expect(
|
expect(
|
||||||
found,
|
found,
|
||||||
isNotNull,
|
isNotNull,
|
||||||
reason: 'Manually-set role should be preserved after sync',
|
reason: 'Manually-set role should be preserved after sync',
|
||||||
);
|
);
|
||||||
expect(found!.path, 'Archive');
|
expect(found!.path, 'Archive');
|
||||||
// Suppress unused warning on spy.
|
// Suppress unused warning on spy.
|
||||||
expect(spy, isNotNull);
|
expect(spy, isNotNull);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,17 +178,17 @@ void main() {
|
|||||||
|
|
||||||
// v28: mime_tree_json column on email_bodies.
|
// v28: mime_tree_json column on email_bodies.
|
||||||
await db
|
await db
|
||||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
.customSelect(
|
||||||
|
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||||
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
// v29: local_sieve_scripts table.
|
// v29: local_sieve_scripts table.
|
||||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||||
|
|
||||||
// v30: duration_ms column on sync_log_mailboxes.
|
// v30: duration_ms column on sync_log_mailboxes.
|
||||||
final syncLogMailboxColumns = await _tableColumns(
|
final syncLogMailboxColumns =
|
||||||
db,
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
'sync_log_mailboxes',
|
|
||||||
);
|
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
|
|
||||||
// v32: local_sieve_applied table.
|
// v32: local_sieve_applied table.
|
||||||
@@ -214,14 +214,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
|
||||||
() async {
|
() async {
|
||||||
final dbFile = File('test_migration_v22.db');
|
final dbFile = File('test_migration_v22.db');
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
|
||||||
// Build a v22 database schema directly with raw SQL.
|
// Build a v22 database schema directly with raw SQL.
|
||||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE accounts (
|
CREATE TABLE accounts (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
@@ -242,7 +242,7 @@ void main() {
|
|||||||
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
|
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE drafts (
|
CREATE TABLE drafts (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id TEXT NULL,
|
account_id TEXT NULL,
|
||||||
@@ -254,7 +254,7 @@ void main() {
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE mailboxes (
|
CREATE TABLE mailboxes (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
@@ -265,7 +265,7 @@ void main() {
|
|||||||
role TEXT NULL
|
role TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE emails (
|
CREATE TABLE emails (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
@@ -289,7 +289,7 @@ void main() {
|
|||||||
snoozed_from_mailbox_path TEXT NULL
|
snoozed_from_mailbox_path TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE threads (
|
CREATE TABLE threads (
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
mailbox_path TEXT NOT NULL,
|
mailbox_path TEXT NOT NULL,
|
||||||
@@ -306,7 +306,7 @@ void main() {
|
|||||||
PRIMARY KEY (account_id, mailbox_path, id)
|
PRIMARY KEY (account_id, mailbox_path, id)
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE email_bodies (
|
CREATE TABLE email_bodies (
|
||||||
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
|
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails(id) ON DELETE CASCADE,
|
||||||
text_body TEXT NULL,
|
text_body TEXT NULL,
|
||||||
@@ -316,7 +316,7 @@ void main() {
|
|||||||
headers_json TEXT NULL
|
headers_json TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE sync_logs (
|
CREATE TABLE sync_logs (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id TEXT NOT NULL,
|
account_id TEXT NOT NULL,
|
||||||
@@ -333,7 +333,7 @@ void main() {
|
|||||||
protocol_log TEXT NULL
|
protocol_log TEXT NULL
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('''
|
rawDb.execute('''
|
||||||
CREATE TABLE sync_log_mailboxes (
|
CREATE TABLE sync_log_mailboxes (
|
||||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
|
sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE,
|
||||||
@@ -343,79 +343,77 @@ void main() {
|
|||||||
bytes_transferred INTEGER NOT NULL DEFAULT 0
|
bytes_transferred INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
''');
|
''');
|
||||||
rawDb.execute('PRAGMA user_version = 22;');
|
rawDb.execute('PRAGMA user_version = 22;');
|
||||||
rawDb.close();
|
rawDb.close();
|
||||||
|
|
||||||
final db = AppDatabase(NativeDatabase(dbFile));
|
final db = AppDatabase(NativeDatabase(dbFile));
|
||||||
// Trigger migration.
|
// Trigger migration.
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
final emailColumns = await _tableColumns(db, 'emails');
|
final emailColumns = await _tableColumns(db, 'emails');
|
||||||
expect(emailColumns, contains('list_unsubscribe_header'));
|
expect(emailColumns, contains('list_unsubscribe_header'));
|
||||||
|
|
||||||
final draftColumns = await _tableColumns(db, 'drafts');
|
final draftColumns = await _tableColumns(db, 'drafts');
|
||||||
expect(draftColumns, contains('imap_server_id'));
|
expect(draftColumns, contains('imap_server_id'));
|
||||||
|
|
||||||
// v25: new indexes on mailboxes and threads.
|
// v25: new indexes on mailboxes and threads.
|
||||||
final allIndexes = await db
|
final allIndexes = await db
|
||||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
.get();
|
.get();
|
||||||
final indexNames =
|
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||||
allIndexes.map((r) => r.read<String>('name')).toSet();
|
expect(indexNames, contains('mailboxes_account_id'));
|
||||||
expect(indexNames, contains('mailboxes_account_id'));
|
expect(indexNames, contains('threads_latest_date'));
|
||||||
expect(indexNames, contains('threads_latest_date'));
|
|
||||||
|
|
||||||
// v26: FTS5 virtual table and triggers.
|
// v26: FTS5 virtual table and triggers.
|
||||||
final allTriggers = await db
|
final allTriggers = await db
|
||||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||||
.get();
|
.get();
|
||||||
final triggerNames =
|
final triggerNames =
|
||||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||||
expect(
|
expect(
|
||||||
triggerNames,
|
triggerNames,
|
||||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||||
);
|
);
|
||||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||||
|
|
||||||
// v27: search_history_entries table.
|
// v27: search_history_entries table.
|
||||||
await db
|
await db
|
||||||
.customSelect('SELECT count(*) FROM search_history_entries')
|
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
// v28: mime_tree_json column on email_bodies.
|
// v28: mime_tree_json column on email_bodies.
|
||||||
await db
|
await db
|
||||||
.customSelect('SELECT mime_tree_json FROM email_bodies LIMIT 0')
|
.customSelect(
|
||||||
.get();
|
'SELECT mime_tree_json FROM email_bodies LIMIT 0',
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
// v29: local_sieve_scripts table.
|
// v29: local_sieve_scripts table.
|
||||||
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
|
||||||
|
|
||||||
// v30: duration_ms column on sync_log_mailboxes.
|
// v30: duration_ms column on sync_log_mailboxes.
|
||||||
final syncLogMailboxColumns = await _tableColumns(
|
final syncLogMailboxColumns =
|
||||||
db,
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
'sync_log_mailboxes',
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
);
|
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
|
||||||
|
|
||||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||||
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
final syncLogColumns = await _tableColumns(db, 'sync_logs');
|
||||||
expect(syncLogColumns, contains('error_stack_trace'));
|
expect(syncLogColumns, contains('error_stack_trace'));
|
||||||
expect(syncLogColumns, contains('is_permanent'));
|
expect(syncLogColumns, contains('is_permanent'));
|
||||||
|
|
||||||
// v34: user_preferences table.
|
// v34: user_preferences table.
|
||||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||||
|
|
||||||
// v35: mail_view_button_position column on user_preferences.
|
// v35: mail_view_button_position column on user_preferences.
|
||||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||||
|
|
||||||
// v36: after_mail_view_action column on user_preferences.
|
// v36: after_mail_view_action column on user_preferences.
|
||||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 36', () async {
|
test('fresh install creates all tables at schemaVersion 36', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
@@ -455,10 +453,8 @@ void main() {
|
|||||||
expect(draftColumns, contains('imap_server_id'));
|
expect(draftColumns, contains('imap_server_id'));
|
||||||
|
|
||||||
// v30: duration_ms column on sync_log_mailboxes.
|
// v30: duration_ms column on sync_log_mailboxes.
|
||||||
final syncLogMailboxColumns = await _tableColumns(
|
final syncLogMailboxColumns =
|
||||||
db,
|
await _tableColumns(db, 'sync_log_mailboxes');
|
||||||
'sync_log_mailboxes',
|
|
||||||
);
|
|
||||||
expect(syncLogMailboxColumns, contains('duration_ms'));
|
expect(syncLogMailboxColumns, contains('duration_ms'));
|
||||||
|
|
||||||
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
// v33: error_stack_trace and is_permanent columns on sync_logs.
|
||||||
|
|||||||
@@ -9,15 +9,14 @@ void main() {
|
|||||||
// absent at startup, throwing MissingPluginException (or a similar error).
|
// absent at startup, throwing MissingPluginException (or a similar error).
|
||||||
// initNotifications() must absorb the failure and let the app continue.
|
// initNotifications() must absorb the failure and let the app continue.
|
||||||
test(
|
test(
|
||||||
'initNotifications completes without throwing when plugin is unavailable',
|
'initNotifications completes without throwing when plugin is unavailable',
|
||||||
() async {
|
() async {
|
||||||
// In the unit-test environment the native plugin is not registered, so
|
// In the unit-test environment the native plugin is not registered, so
|
||||||
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
// _plugin.initialize() throws. The fix catches it and keeps _initialized
|
||||||
// false. This test fails before the fix (exception propagates) and passes
|
// false. This test fails before the fix (exception propagates) and passes
|
||||||
// after it (exception is swallowed).
|
// after it (exception is swallowed).
|
||||||
await expectLater(initNotifications(), completes);
|
await expectLater(initNotifications(), completes);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('showNewMailNotification completes without throwing', () async {
|
test('showNewMailNotification completes without throwing', () async {
|
||||||
// Platform.isAndroid is false in tests, so this returns early without
|
// Platform.isAndroid is false in tests, so this returns early without
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ class _FakeAccounts implements AccountRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
||||||
@override
|
@override
|
||||||
Future<Account?> getAccount(String id) async => accounts
|
Future<Account?> getAccount(String id) async =>
|
||||||
.cast<Account?>()
|
accounts.cast<Account?>().firstWhere(
|
||||||
.firstWhere((a) => a?.id == id, orElse: () => null);
|
(a) => a?.id == id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
Future<void> addAccount(Account account, String password) async {}
|
Future<void> addAccount(Account account, String password) async {}
|
||||||
@override
|
@override
|
||||||
@@ -92,7 +94,11 @@ class _CountingEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
||||||
@override
|
@override
|
||||||
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
Stream<List<Email>> observeEmails(
|
||||||
|
String a,
|
||||||
|
String m, {
|
||||||
|
int limit = 50,
|
||||||
|
}) =>
|
||||||
Stream.value([]);
|
Stream.value([]);
|
||||||
@override
|
@override
|
||||||
Stream<List<EmailThread>> observeThreads(
|
Stream<List<EmailThread>> observeThreads(
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ void main() {
|
|||||||
test('parsePublicKeyQr returns null for invalid input', () {
|
test('parsePublicKeyQr returns null for invalid input', () {
|
||||||
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
|
expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull);
|
||||||
expect(
|
expect(
|
||||||
ShareEncryptionService.parsePublicKeyQr('sharedinbox.de:pubkey:v1:!!!'),
|
ShareEncryptionService.parsePublicKeyQr(
|
||||||
|
'sharedinbox.de:pubkey:v1:!!!',
|
||||||
|
),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ void main() {
|
|||||||
SieveRule(
|
SieveRule(
|
||||||
joinType: 'single',
|
joinType: 'single',
|
||||||
conditions: [
|
conditions: [
|
||||||
HeaderCondition(['from', 'reply-to'], ':is', ['boss@work.com']),
|
HeaderCondition(
|
||||||
|
['from', 'reply-to'],
|
||||||
|
':is',
|
||||||
|
['boss@work.com'],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
FlagAction([r'\Important']),
|
FlagAction([r'\Important']),
|
||||||
@@ -117,10 +121,8 @@ void main() {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final ctx = interp.execute(
|
final ctx =
|
||||||
rules,
|
interp.execute(rules, _email(subject: 'Weekly Newsletter Issue'));
|
||||||
_email(subject: 'Weekly Newsletter Issue'),
|
|
||||||
);
|
|
||||||
expect(ctx.targetFolders, contains('Bulk'));
|
expect(ctx.targetFolders, contains('Bulk'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -261,9 +261,8 @@ if exists "X-Spam-Flag" {
|
|||||||
|
|
||||||
group('SieveParser — rule model', () {
|
group('SieveParser — rule model', () {
|
||||||
test('simple if produces one rule with branchGroupId', () {
|
test('simple if produces one rule with branchGroupId', () {
|
||||||
final rules = parser.parse(
|
final rules =
|
||||||
'if header :contains "Subject" "x" { discard; }',
|
parser.parse('if header :contains "Subject" "x" { discard; }');
|
||||||
);
|
|
||||||
expect(rules, hasLength(1));
|
expect(rules, hasLength(1));
|
||||||
expect(rules.first.branchGroupId, isNotNull);
|
expect(rules.first.branchGroupId, isNotNull);
|
||||||
expect(rules.first.conditions, hasLength(1));
|
expect(rules.first.conditions, hasLength(1));
|
||||||
|
|||||||
@@ -127,35 +127,33 @@ void main() {
|
|||||||
expect(rows.first.errorMessage, 'Connection refused');
|
expect(rows.first.errorMessage, 'Connection refused');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('stores and retrieves stackTrace and isPermanent on error entries',
|
||||||
'stores and retrieves stackTrace and isPermanent on error entries',
|
() async {
|
||||||
() async {
|
final repo = SyncLogRepositoryImpl(db);
|
||||||
final repo = SyncLogRepositoryImpl(db);
|
final start = DateTime(2024, 3, 1, 9);
|
||||||
final start = DateTime(2024, 3, 1, 9);
|
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
||||||
final end = DateTime(2024, 3, 1, 9, 0, 1);
|
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
||||||
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
|
|
||||||
|
|
||||||
await repo.log(
|
await repo.log(
|
||||||
accountId: 'acc1',
|
accountId: 'acc1',
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: 'MissingPluginException',
|
errorMessage: 'MissingPluginException',
|
||||||
stackTrace: fakeTrace,
|
stackTrace: fakeTrace,
|
||||||
isPermanent: true,
|
isPermanent: true,
|
||||||
protocol: 'imap',
|
protocol: 'imap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
mailboxesSynced: 0,
|
mailboxesSynced: 0,
|
||||||
pendingFlushed: 0,
|
pendingFlushed: 0,
|
||||||
bytesTransferred: 0,
|
bytesTransferred: 0,
|
||||||
startedAt: start,
|
startedAt: start,
|
||||||
finishedAt: end,
|
finishedAt: end,
|
||||||
);
|
);
|
||||||
|
|
||||||
final entries = await repo.observeSyncLogs('acc1').first;
|
final entries = await repo.observeSyncLogs('acc1').first;
|
||||||
final entry = entries.firstWhere((e) => e.startedAt == start);
|
final entry = entries.firstWhere((e) => e.startedAt == start);
|
||||||
expect(entry.stackTrace, fakeTrace);
|
expect(entry.stackTrace, fakeTrace);
|
||||||
expect(entry.isPermanent, true);
|
expect(entry.isPermanent, true);
|
||||||
expect(entry.errorMessage, 'MissingPluginException');
|
expect(entry.errorMessage, 'MissingPluginException');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,9 +260,8 @@ void main() {
|
|||||||
expect(original!.messageId, isNull); // set a messageId so lookup works
|
expect(original!.messageId, isNull); // set a messageId so lookup works
|
||||||
|
|
||||||
// Seed a messageId so undo can find the email after UID change.
|
// Seed a messageId so undo can find the email after UID change.
|
||||||
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId))).write(
|
await (db.update(db.emails)..where((t) => t.id.equals(oldEmailId)))
|
||||||
const EmailsCompanion(messageId: Value('msg-101@test')),
|
.write(const EmailsCompanion(messageId: Value('msg-101@test')));
|
||||||
);
|
|
||||||
|
|
||||||
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
final originalWithMsgId = await repo.getEmail(oldEmailId);
|
||||||
|
|
||||||
@@ -304,9 +303,8 @@ void main() {
|
|||||||
await container.read(undoServiceProvider.notifier).undo();
|
await container.read(undoServiceProvider.notifier).undo();
|
||||||
|
|
||||||
// 4. Verify the current email row is now in INBOX.
|
// 4. Verify the current email row is now in INBOX.
|
||||||
final inInbox = await (db.select(
|
final inInbox = await (db.select(db.emails)
|
||||||
db.emails,
|
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||||
)..where((t) => t.mailboxPath.equals('INBOX')))
|
|
||||||
.get();
|
.get();
|
||||||
expect(
|
expect(
|
||||||
inInbox,
|
inInbox,
|
||||||
|
|||||||
@@ -122,74 +122,70 @@ void main() {
|
|||||||
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('undo pushes inverse action into log when destinationMailboxPath is set',
|
||||||
'undo pushes inverse action into log when destinationMailboxPath is set',
|
() async {
|
||||||
() async {
|
final action = UndoAction(
|
||||||
final action = UndoAction(
|
id: 'del1',
|
||||||
id: 'del1',
|
accountId: 'acc1',
|
||||||
accountId: 'acc1',
|
type: UndoType.delete,
|
||||||
type: UndoType.delete,
|
emailIds: ['e1'],
|
||||||
emailIds: ['e1'],
|
sourceMailboxPath: 'INBOX',
|
||||||
sourceMailboxPath: 'INBOX',
|
destinationMailboxPath: 'Trash',
|
||||||
destinationMailboxPath: 'Trash',
|
);
|
||||||
);
|
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(
|
when(
|
||||||
mockEmailRepo.cancelPendingChange(any, any),
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
).thenAnswer((_) async => false);
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
await notifier.pushAction(action);
|
await notifier.pushAction(action);
|
||||||
await notifier.undo(actionId: 'del1');
|
await notifier.undo(actionId: 'del1');
|
||||||
|
|
||||||
// Original entry stays; inverse is added.
|
// Original entry stays; inverse is added.
|
||||||
final log = container.read(undoServiceProvider);
|
final log = container.read(undoServiceProvider);
|
||||||
expect(log.length, 2);
|
expect(log.length, 2);
|
||||||
expect(log[0].id, 'del1');
|
expect(log[0].id, 'del1');
|
||||||
final inv = log[1];
|
final inv = log[1];
|
||||||
expect(inv.id, 'del1-inv');
|
expect(inv.id, 'del1-inv');
|
||||||
expect(inv.type, UndoType.move);
|
expect(inv.type, UndoType.move);
|
||||||
expect(inv.emailIds, ['e1']);
|
expect(inv.emailIds, ['e1']);
|
||||||
expect(inv.sourceMailboxPath, 'Trash');
|
expect(inv.sourceMailboxPath, 'Trash');
|
||||||
expect(inv.destinationMailboxPath, 'INBOX');
|
expect(inv.destinationMailboxPath, 'INBOX');
|
||||||
verify(
|
verify(
|
||||||
mockUndoRepo.saveAction(
|
mockUndoRepo.saveAction(
|
||||||
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
||||||
),
|
),
|
||||||
).called(1);
|
).called(1);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('undo without destinationMailboxPath does not push inverse action',
|
||||||
'undo without destinationMailboxPath does not push inverse action',
|
() async {
|
||||||
() async {
|
final action = UndoAction(
|
||||||
final action = UndoAction(
|
id: 'mv1',
|
||||||
id: 'mv1',
|
accountId: 'acc1',
|
||||||
accountId: 'acc1',
|
type: UndoType.move,
|
||||||
type: UndoType.move,
|
emailIds: ['e1'],
|
||||||
emailIds: ['e1'],
|
sourceMailboxPath: 'INBOX',
|
||||||
sourceMailboxPath: 'INBOX',
|
// no destinationMailboxPath
|
||||||
// no destinationMailboxPath
|
);
|
||||||
);
|
|
||||||
|
|
||||||
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
||||||
when(
|
when(
|
||||||
mockEmailRepo.cancelPendingChange(any, any),
|
mockEmailRepo.cancelPendingChange(any, any),
|
||||||
).thenAnswer((_) async => false);
|
).thenAnswer((_) async => false);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
await notifier.init();
|
await notifier.init();
|
||||||
await notifier.pushAction(action);
|
await notifier.pushAction(action);
|
||||||
await notifier.undo(actionId: 'mv1');
|
await notifier.undo(actionId: 'mv1');
|
||||||
|
|
||||||
// Original entry stays; no inverse since no destinationMailboxPath.
|
// Original entry stays; no inverse since no destinationMailboxPath.
|
||||||
final log = container.read(undoServiceProvider);
|
final log = container.read(undoServiceProvider);
|
||||||
expect(log.length, 1);
|
expect(log.length, 1);
|
||||||
expect(log.first.id, 'mv1');
|
expect(log.first.id, 'mv1');
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
test('undo with actionId removes and undos specific action', () async {
|
test('undo with actionId removes and undos specific action', () async {
|
||||||
// action1 has no destination → no inverse action
|
// action1 has no destination → no inverse action
|
||||||
@@ -354,9 +350,13 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Simulate slow DB load
|
// Simulate slow DB load
|
||||||
when(mockUndoRepo.getHistory(limit: anyNamed('limit'))).thenAnswer(
|
when(
|
||||||
(_) =>
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
||||||
Future.delayed(const Duration(milliseconds: 10), () => [persisted]),
|
).thenAnswer(
|
||||||
|
(_) => Future.delayed(
|
||||||
|
const Duration(milliseconds: 10),
|
||||||
|
() => [persisted],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final notifier = container.read(undoServiceProvider.notifier);
|
final notifier = container.read(undoServiceProvider.notifier);
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ class ThrowingUrlLauncher extends Mock
|
|||||||
Widget _buildScreen({List<Account> accounts = const []}) {
|
Widget _buildScreen({List<Account> accounts = const []}) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider
|
||||||
FakeAccountRepository(accounts),
|
.overrideWithValue(FakeAccountRepository(accounts)),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: AboutScreen()),
|
child: const MaterialApp(home: AboutScreen()),
|
||||||
);
|
);
|
||||||
@@ -152,10 +151,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
addTearDown(
|
addTearDown(
|
||||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
() => tester.binding.defaultBinaryMessenger
|
||||||
SystemChannels.platform,
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
null,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(_buildScreen());
|
await tester.pumpWidget(_buildScreen());
|
||||||
@@ -176,7 +173,10 @@ void main() {
|
|||||||
expect(clipboardText, contains('Locale'));
|
expect(clipboardText, contains('Locale'));
|
||||||
expect(clipboardText, contains('Text Scale'));
|
expect(clipboardText, contains('Text Scale'));
|
||||||
expect(clipboardText, contains('DB Schema Version'));
|
expect(clipboardText, contains('DB Schema Version'));
|
||||||
expect(clipboardText, contains('[sharedinbox.de](https://sharedinbox.de)'));
|
expect(
|
||||||
|
clipboardText,
|
||||||
|
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
testWidgets('AboutScreen create-issue button opens Codeberg URL', (
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ void main() {
|
|||||||
recipientKeyId: material.keyId,
|
recipientKeyId: material.keyId,
|
||||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
recipientPublicKeyBytes: material.publicKeyBytes,
|
||||||
accounts: [
|
accounts: [
|
||||||
AccountPayload(accountJson: account.toJson(), password: 'secret'),
|
AccountPayload(
|
||||||
|
accountJson: account.toJson(),
|
||||||
|
password: 'secret',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,7 +99,10 @@ void main() {
|
|||||||
await tester.tap(find.text('Import'));
|
await tester.tap(find.text('Import'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Imported 1 account successfully.'), findsOneWidget);
|
expect(
|
||||||
|
find.text('Imported 1 account successfully.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -227,53 +227,54 @@ void main() {
|
|||||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows discrepancy details when sync health has discrepancies',
|
testWidgets(
|
||||||
(
|
'shows discrepancy details when sync health has discrepancies',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
const summary =
|
||||||
const summary =
|
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
await tester.pumpWidget(
|
||||||
await tester.pumpWidget(
|
buildApp(
|
||||||
buildApp(
|
initialLocation: '/accounts',
|
||||||
initialLocation: '/accounts',
|
overrides: baseOverrides(
|
||||||
overrides: baseOverrides(
|
accounts: [kTestAccount],
|
||||||
accounts: [kTestAccount],
|
syncHealth: SyncHealthRow(
|
||||||
syncHealth: SyncHealthRow(
|
accountId: kTestAccount.id,
|
||||||
accountId: kTestAccount.id,
|
lastVerifiedAt: DateTime(2024, 6),
|
||||||
lastVerifiedAt: DateTime(2024, 6),
|
isHealthy: false,
|
||||||
isHealthy: false,
|
discrepancySummary: summary,
|
||||||
discrepancySummary: summary,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
await tester.pumpAndSettle();
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('sync health row is positioned below the account name row', (
|
testWidgets(
|
||||||
tester,
|
'sync health row is positioned below the account name row',
|
||||||
) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/accounts',
|
||||||
overrides: baseOverrides(
|
overrides: baseOverrides(
|
||||||
accounts: [kTestAccount],
|
accounts: [kTestAccount],
|
||||||
syncHealth: SyncHealthRow(
|
syncHealth: SyncHealthRow(
|
||||||
accountId: kTestAccount.id,
|
accountId: kTestAccount.id,
|
||||||
lastVerifiedAt: DateTime(2024, 6),
|
lastVerifiedAt: DateTime(2024, 6),
|
||||||
isHealthy: true,
|
isHealthy: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
await tester.pumpAndSettle();
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
||||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
||||||
expect(healthPos, greaterThan(namePos));
|
expect(healthPos, greaterThan(namePos));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
addTearDown(
|
addTearDown(
|
||||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
() => tester.binding.defaultBinaryMessenger
|
||||||
SystemChannels.platform,
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
null,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const exception = 'TestException: clipboard test';
|
const exception = 'TestException: clipboard test';
|
||||||
@@ -128,77 +126,79 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('CrashScreen shows git hash as clickable link above stacktrace', (
|
testWidgets(
|
||||||
tester,
|
'CrashScreen shows git hash as clickable link above stacktrace',
|
||||||
) async {
|
(tester) async {
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() => tester.view.resetPhysicalSize());
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
final mock = MockUrlLauncher();
|
final mock = MockUrlLauncher();
|
||||||
UrlLauncherPlatform.instance = mock;
|
UrlLauncherPlatform.instance = mock;
|
||||||
|
|
||||||
const exception = 'TestException: git hash test';
|
const exception = 'TestException: git hash test';
|
||||||
final stackTrace = StackTrace.current;
|
final stackTrace = StackTrace.current;
|
||||||
const testHash = 'abc1234';
|
const testHash = 'abc1234';
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
CrashScreen(
|
CrashScreen(
|
||||||
exception: exception,
|
exception: exception,
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
gitHash: testHash,
|
gitHash: testHash,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Git hash link should be present
|
// Git hash link should be present
|
||||||
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
final gitLinkFinder = find.textContaining('Git Commit: abc1234');
|
||||||
expect(gitLinkFinder, findsOneWidget);
|
expect(gitLinkFinder, findsOneWidget);
|
||||||
|
|
||||||
// Link must appear above the stack trace
|
// Link must appear above the stack trace
|
||||||
final stackTraceFinder = find.text('Stack Trace:');
|
final stackTraceFinder = find.text('Stack Trace:');
|
||||||
expect(
|
expect(
|
||||||
tester.getTopLeft(gitLinkFinder).dy,
|
tester.getTopLeft(gitLinkFinder).dy,
|
||||||
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
lessThan(tester.getTopLeft(stackTraceFinder).dy),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tapping the link should open the Codeberg commit URL
|
// Tapping the link should open the Codeberg commit URL
|
||||||
await tester.tap(gitLinkFinder);
|
await tester.tap(gitLinkFinder);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
mock.launchedUrl,
|
mock.launchedUrl,
|
||||||
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('CrashScreen shows version, build mode, and platform in the UI', (
|
testWidgets(
|
||||||
tester,
|
'CrashScreen shows version, build mode, and platform in the UI',
|
||||||
) async {
|
(tester) async {
|
||||||
tester.view.physicalSize = const Size(800, 1200);
|
tester.view.physicalSize = const Size(800, 1200);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() => tester.view.resetPhysicalSize());
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
const exception = 'TestException: info row test';
|
const exception = 'TestException: info row test';
|
||||||
final stackTrace = StackTrace.current;
|
final stackTrace = StackTrace.current;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Info row shows app version (from mock), build mode, and platform OS.
|
// Info row shows app version (from mock), build mode, and platform OS.
|
||||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||||
// In test builds kDebugMode is true.
|
// In test builds kDebugMode is true.
|
||||||
expect(find.textContaining('debug'), findsOneWidget);
|
expect(find.textContaining('debug'), findsOneWidget);
|
||||||
// Platform OS is always present (linux in CI, android/ios on device).
|
// Platform OS is always present (linux in CI, android/ios on device).
|
||||||
expect(
|
expect(
|
||||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||||
findsWidgets,
|
findsWidgets,
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'CrashScreen shows app version as clickable link when git hash is set',
|
'CrashScreen shows app version as clickable link when git hash is set',
|
||||||
@@ -264,10 +264,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
addTearDown(
|
addTearDown(
|
||||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
() => tester.binding.defaultBinaryMessenger
|
||||||
SystemChannels.platform,
|
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||||
null,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const exception = 'TestException: version link clipboard test';
|
const exception = 'TestException: version link clipboard test';
|
||||||
|
|||||||
@@ -106,62 +106,62 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'try connection button is disabled when no password stored or entered',
|
'try connection button is disabled when no password stored or entered',
|
||||||
(tester) async {
|
(
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester,
|
||||||
tester.view.devicePixelRatio = 1.0;
|
) async {
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
overrides: baseOverrides(
|
overrides: baseOverrides(
|
||||||
accounts: [kTestAccount],
|
accounts: [kTestAccount],
|
||||||
hasStoredPassword: false,
|
hasStoredPassword: false,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
await tester.pumpAndSettle();
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
final button = tester.widget<OutlinedButton>(
|
||||||
find.byKey(const Key('editTryConnectionButton')),
|
find.byKey(const Key('editTryConnectionButton')),
|
||||||
);
|
);
|
||||||
expect(button.onPressed, isNull);
|
expect(button.onPressed, isNull);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'try connection button is enabled after typing password with no stored password',
|
'try connection button is enabled after typing password with no stored password',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/edit',
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
overrides: baseOverrides(
|
overrides: baseOverrides(
|
||||||
accounts: [kTestAccount],
|
accounts: [kTestAccount],
|
||||||
hasStoredPassword: false,
|
hasStoredPassword: false,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
await tester.pumpAndSettle();
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const Key('editPasswordField')),
|
find.byKey(const Key('editPasswordField')),
|
||||||
'mypassword',
|
'mypassword',
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final button = tester.widget<OutlinedButton>(
|
final button = tester.widget<OutlinedButton>(
|
||||||
find.byKey(const Key('editTryConnectionButton')),
|
find.byKey(const Key('editTryConnectionButton')),
|
||||||
);
|
);
|
||||||
expect(button.onPressed, isNotNull);
|
expect(button.onPressed, isNotNull);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets('save button is disabled when no password stored or entered', (
|
testWidgets('save button is disabled when no password stored or entered', (
|
||||||
tester,
|
tester,
|
||||||
@@ -182,9 +182,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final button = tester.widget<FilledButton>(
|
final button = tester
|
||||||
find.widgetWithText(FilledButton, 'Save'),
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
||||||
);
|
|
||||||
expect(button.onPressed, isNull);
|
expect(button.onPressed, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ List<Override> _overrides({required EmailBody body, Email? email}) => [
|
|||||||
),
|
),
|
||||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
|
FakeEmailRepository(
|
||||||
|
emailDetail: email ?? testEmail(),
|
||||||
|
emailBody: body,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -188,45 +191,45 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply all'),
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Reply all',
|
||||||
|
),
|
||||||
findsNothing,
|
findsNothing,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('Reply on single-recipient email navigates directly to compose',
|
||||||
'Reply on single-recipient email navigates directly to compose',
|
(tester) async {
|
||||||
(tester) async {
|
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||||
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
// only bob remains → no dialog, navigate straight to compose.
|
||||||
// only bob remains → no dialog, navigate straight to compose.
|
final email = testEmail();
|
||||||
final email = testEmail();
|
await tester.pumpWidget(
|
||||||
await tester.pumpWidget(
|
buildApp(
|
||||||
buildApp(
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
initialLocation:
|
overrides: [
|
||||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
..._overrides(
|
||||||
overrides: [
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
..._overrides(
|
email: email,
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
),
|
||||||
email: email,
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
),
|
],
|
||||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
),
|
||||||
],
|
);
|
||||||
),
|
await tester.pumpAndSettle();
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(
|
await tester.tap(
|
||||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
|
find.byWidgetPredicate(
|
||||||
);
|
(w) => w is Tooltip && w.message == 'Reply',
|
||||||
await tester.pumpAndSettle();
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// No dialog shown — straight navigation to compose.
|
// No dialog shown — straight navigation to compose.
|
||||||
expect(find.text('Reply All'), findsNothing);
|
expect(find.text('Reply All'), findsNothing);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets('Reply on multi-recipient email shows Reply All dialog', (
|
testWidgets('Reply on multi-recipient email shows Reply All dialog',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
|
||||||
// Email with an extra Cc recipient so the dialog is triggered.
|
// Email with an extra Cc recipient so the dialog is triggered.
|
||||||
final email = Email(
|
final email = Email(
|
||||||
id: 'acc-1:42',
|
id: 'acc-1:42',
|
||||||
@@ -255,7 +258,9 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(
|
await tester.tap(
|
||||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Reply'),
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Reply',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -266,9 +271,8 @@ void main() {
|
|||||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Mark as spam is in popup menu, not a standalone button', (
|
testWidgets('Mark as spam is in popup menu, not a standalone button',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
@@ -294,9 +298,8 @@ void main() {
|
|||||||
expect(find.text('Mark as spam'), findsOneWidget);
|
expect(find.text('Mark as spam'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Mark as spam shows dialog when no junk folder', (
|
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
|
||||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||||
// returns null → dialog shown.
|
// returns null → dialog shown.
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -331,7 +334,9 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Archive',
|
||||||
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -350,16 +355,17 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(
|
await tester.tap(
|
||||||
find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Archive'),
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Archive',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No archive folder found'), findsOneWidget);
|
expect(find.text('No archive folder found'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Mark as unread is in popup menu, not a standalone button', (
|
testWidgets('Mark as unread is in popup menu, not a standalone button',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
@@ -395,16 +401,13 @@ void main() {
|
|||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider.overrideWithValue(
|
||||||
FakeAccountRepository([kTestAccount]),
|
FakeAccountRepository([kTestAccount]),
|
||||||
),
|
),
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
mailboxRepositoryProvider
|
||||||
FakeMailboxRepository(),
|
.overrideWithValue(FakeMailboxRepository()),
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(
|
FakeEmailRepository(
|
||||||
emailDetail: testEmail(),
|
emailDetail: testEmail(),
|
||||||
emailBody: const EmailBody(
|
emailBody:
|
||||||
emailId: 'acc-1:42',
|
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
attachments: [],
|
|
||||||
),
|
|
||||||
rawRfc822: rawContent,
|
rawRfc822: rawContent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -433,16 +436,13 @@ void main() {
|
|||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider.overrideWithValue(
|
||||||
FakeAccountRepository([kTestAccount]),
|
FakeAccountRepository([kTestAccount]),
|
||||||
),
|
),
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
mailboxRepositoryProvider
|
||||||
FakeMailboxRepository(),
|
.overrideWithValue(FakeMailboxRepository()),
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(
|
FakeEmailRepository(
|
||||||
emailDetail: testEmail(),
|
emailDetail: testEmail(),
|
||||||
emailBody: const EmailBody(
|
emailBody:
|
||||||
emailId: 'acc-1:42',
|
const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
attachments: [],
|
|
||||||
),
|
|
||||||
rawRfc822: 'Subject: test\r\n\r\nBody',
|
rawRfc822: 'Subject: test\r\n\r\nBody',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -483,37 +483,43 @@ void main() {
|
|||||||
expect(find.text('Share'), findsOneWidget);
|
expect(find.text('Share'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('long-press on unsubscribe chip shows URL tooltip', (
|
testWidgets(
|
||||||
tester,
|
'long-press on unsubscribe chip shows URL tooltip',
|
||||||
) async {
|
(tester) async {
|
||||||
final email = testEmail(
|
final email = testEmail(
|
||||||
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation:
|
||||||
overrides: _overrides(
|
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
overrides: _overrides(
|
||||||
email: email,
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
email: email,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
await tester.pumpAndSettle();
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Unsubscribe'), findsOneWidget);
|
expect(find.text('Unsubscribe'), findsOneWidget);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.byWidgetPredicate(
|
find.byWidgetPredicate(
|
||||||
(w) => w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
(w) =>
|
||||||
),
|
w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||||
findsOneWidget,
|
),
|
||||||
);
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.longPress(find.text('Unsubscribe'));
|
await tester.longPress(find.text('Unsubscribe'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('https://example.com/unsubscribe'), findsOneWidget);
|
expect(
|
||||||
});
|
find.text('https://example.com/unsubscribe'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('Show Mail Structure opens dialog with MIME parts', (
|
testWidgets('Show Mail Structure opens dialog with MIME parts', (
|
||||||
tester,
|
tester,
|
||||||
@@ -557,31 +563,36 @@ void main() {
|
|||||||
expect(find.textContaining('application/pdf'), findsOneWidget);
|
expect(find.textContaining('application/pdf'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Show Mail Structure shows snackbar when mimeTree is absent', (
|
testWidgets(
|
||||||
tester,
|
'Show Mail Structure shows snackbar when mimeTree is absent',
|
||||||
) async {
|
(tester) async {
|
||||||
const body = EmailBody(
|
const body = EmailBody(
|
||||||
emailId: 'acc-1:42',
|
emailId: 'acc-1:42',
|
||||||
textBody: 'Hello',
|
textBody: 'Hello',
|
||||||
attachments: [],
|
attachments: [],
|
||||||
// mimeTree is null — not yet cached or not available.
|
// mimeTree is null — not yet cached or not available.
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation:
|
||||||
overrides: _overrides(body: body),
|
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
),
|
overrides: _overrides(body: body),
|
||||||
);
|
),
|
||||||
await tester.pumpAndSettle();
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Show Mail Structure'));
|
await tester.tap(find.text('Show Mail Structure'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('Structure not available'), findsOneWidget);
|
expect(
|
||||||
});
|
find.textContaining('Structure not available'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ List<Override> _overrides({
|
|||||||
searchHistoryRepositoryProvider.overrideWithValue(
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
FakeSearchHistoryRepository(),
|
FakeSearchHistoryRepository(),
|
||||||
),
|
),
|
||||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
|
syncLastErrorProvider.overrideWith(
|
||||||
|
(ref, _) => Stream.value(syncError),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -120,7 +122,9 @@ void main() {
|
|||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
overrides: _overrides(
|
overrides: _overrides(
|
||||||
searchResults: [_email(id: 'acc-1:5', subject: 'Project proposal')],
|
searchResults: [
|
||||||
|
_email(id: 'acc-1:5', subject: 'Project proposal'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -430,62 +430,63 @@ void main() {
|
|||||||
expect(find.text('Result email'), findsWidgets);
|
expect(find.text('Result email'), findsWidgets);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('deleting all search results pops back to previous screen', (
|
testWidgets(
|
||||||
tester,
|
'deleting all search results pops back to previous screen',
|
||||||
) async {
|
(tester) async {
|
||||||
final email = testEmail(subject: 'Needle');
|
final email = testEmail(subject: 'Needle');
|
||||||
|
|
||||||
// Start at the mailbox list so the email list is pushed on top of it,
|
// Start at the mailbox list so the email list is pushed on top of it,
|
||||||
// making context.canPop() == true inside EmailListScreen.
|
// making context.canPop() == true inside EmailListScreen.
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes',
|
initialLocation: '/accounts/acc-1/mailboxes',
|
||||||
overrides: [
|
overrides: [
|
||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider.overrideWithValue(
|
||||||
FakeAccountRepository([kTestAccount]),
|
FakeAccountRepository([kTestAccount]),
|
||||||
),
|
),
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
FakeMailboxRepository([kTestMailbox]),
|
FakeMailboxRepository([kTestMailbox]),
|
||||||
),
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(searchResults: [email]),
|
FakeEmailRepository(searchResults: [email]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||||
|
|
||||||
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
||||||
await tester.tap(find.text('INBOX'));
|
await tester.tap(find.text('INBOX'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(EmailListScreen), findsOneWidget);
|
expect(find.byType(EmailListScreen), findsOneWidget);
|
||||||
|
|
||||||
// Search for the email.
|
// Search for the email.
|
||||||
await tester.enterText(find.byType(TextField), 'Needle');
|
await tester.enterText(find.byType(TextField), 'Needle');
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// 'Needle' also appears in the SearchBar input, so match at least one.
|
// 'Needle' also appears in the SearchBar input, so match at least one.
|
||||||
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
||||||
|
|
||||||
// Long-press the sender name (unique to the email tile) to enter
|
// Long-press the sender name (unique to the email tile) to enter
|
||||||
// selection mode.
|
// selection mode.
|
||||||
await tester.longPress(find.text('Bob'));
|
await tester.longPress(find.text('Bob'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.select_all));
|
await tester.tap(find.byIcon(Icons.select_all));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.delete));
|
await tester.tap(find.byIcon(Icons.delete));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Should have popped back to the mailbox list.
|
// Should have popped back to the mailbox list.
|
||||||
expect(find.byType(EmailListScreen), findsNothing);
|
expect(find.byType(EmailListScreen), findsNothing);
|
||||||
expect(find.byType(MailboxListScreen), findsOneWidget);
|
expect(find.byType(MailboxListScreen), findsOneWidget);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'deleting some search results updates the list without popping',
|
'deleting some search results updates the list without popping',
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ void main() {
|
|||||||
expect(find.text('No results'), findsOneWidget);
|
expect(find.text('No results'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows email results under "Messages" section', (tester) async {
|
testWidgets('shows email results under "Messages" section', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
final email = testEmail(subject: 'Invoice Q3');
|
final email = testEmail(subject: 'Invoice Q3');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
@@ -120,7 +122,9 @@ void main() {
|
|||||||
expect(find.text('Invoice Q3'), findsOneWidget);
|
expect(find.text('Invoice Q3'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows folder results under "Folders" section', (tester) async {
|
testWidgets('shows folder results under "Folders" section', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
const archiveMailbox = Mailbox(
|
const archiveMailbox = Mailbox(
|
||||||
id: 'acc-1:Archive',
|
id: 'acc-1:Archive',
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
|
|||||||
@@ -20,12 +20,10 @@ Widget _wrap(Widget child) => MaterialApp(
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('buildEmailHtml', () {
|
group('buildEmailHtml', () {
|
||||||
test(
|
test('forces light color-scheme to prevent black-on-black in dark mode',
|
||||||
'forces light color-scheme to prevent black-on-black in dark mode',
|
() {
|
||||||
() {
|
_expectLightMode(buildEmailHtml('<p>Hello</p>'));
|
||||||
_expectLightMode(buildEmailHtml('<p>Hello</p>'));
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('includes email body content', () {
|
test('includes email body content', () {
|
||||||
final html = buildEmailHtml('<p>Test body</p>');
|
final html = buildEmailHtml('<p>Test body</p>');
|
||||||
@@ -46,9 +44,8 @@ void main() {
|
|||||||
|
|
||||||
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
||||||
() {
|
() {
|
||||||
final html = buildEmailHtml(
|
final html =
|
||||||
'<table width="600"><tr><td>x</td></tr></table>',
|
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
|
||||||
);
|
|
||||||
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
||||||
expect(html, contains('overflow-x: hidden'));
|
expect(html, contains('overflow-x: hidden'));
|
||||||
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
|
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
|
||||||
@@ -65,7 +62,11 @@ void main() {
|
|||||||
group('SecureEmailWebView (Linux plain-text fallback)', () {
|
group('SecureEmailWebView (Linux plain-text fallback)', () {
|
||||||
testWidgets('renders extracted text from HTML', (tester) async {
|
testWidgets('renders extracted text from HTML', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_wrap(const SecureEmailWebView(htmlBody: '<p>Hello <b>world</b></p>')),
|
_wrap(
|
||||||
|
const SecureEmailWebView(
|
||||||
|
htmlBody: '<p>Hello <b>world</b></p>',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(find.textContaining('Hello'), findsOneWidget);
|
expect(find.textContaining('Hello'), findsOneWidget);
|
||||||
expect(find.textContaining('world'), findsOneWidget);
|
expect(find.textContaining('world'), findsOneWidget);
|
||||||
@@ -91,11 +92,12 @@ void main() {
|
|||||||
expect(find.byType(SelectableText), findsOneWidget);
|
expect(find.byType(SelectableText), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('toggling loadRemoteImages rebuilds without error', (
|
testWidgets('toggling loadRemoteImages rebuilds without error',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_wrap(const SecureEmailWebView(htmlBody: '<p>Body</p>')),
|
_wrap(
|
||||||
|
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_wrap(
|
_wrap(
|
||||||
@@ -109,7 +111,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('handles empty HTML body', (tester) async {
|
testWidgets('handles empty HTML body', (tester) async {
|
||||||
await tester.pumpWidget(_wrap(const SecureEmailWebView(htmlBody: '')));
|
await tester.pumpWidget(
|
||||||
|
_wrap(const SecureEmailWebView(htmlBody: '')),
|
||||||
|
);
|
||||||
expect(find.byType(SelectableText), findsOneWidget);
|
expect(find.byType(SelectableText), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ void main() {
|
|||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
sieveRepositoryProvider.overrideWith((ref) => _FakeSieveRepository()),
|
sieveRepositoryProvider.overrideWith(
|
||||||
|
(ref) => _FakeSieveRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: SieveScriptsScreen(accountId: 'acc-1')),
|
child: const MaterialApp(
|
||||||
|
home: SieveScriptsScreen(accountId: 'acc-1'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ void main() {
|
|||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
|
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
|
||||||
);
|
);
|
||||||
when(
|
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
||||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
.thenAnswer((_) async => [staleAction]);
|
||||||
).thenAnswer((_) async => [staleAction]);
|
|
||||||
|
|
||||||
await tester.pumpWidget(buildShell(mockUndoRepo));
|
await tester.pumpWidget(buildShell(mockUndoRepo));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -49,12 +48,10 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('shows snackbar for fresh action pushed in current session', (
|
testWidgets('shows snackbar for fresh action pushed in current session',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
||||||
when(
|
.thenAnswer((_) async => []);
|
||||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
|
||||||
).thenAnswer((_) async => []);
|
|
||||||
|
|
||||||
await tester.pumpWidget(buildShell(mockUndoRepo));
|
await tester.pumpWidget(buildShell(mockUndoRepo));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -67,20 +64,18 @@ void main() {
|
|||||||
emailIds: ['e1'],
|
emailIds: ['e1'],
|
||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
);
|
);
|
||||||
await ProviderScope.containerOf(
|
await ProviderScope.containerOf(context)
|
||||||
context,
|
.read(undoServiceProvider.notifier)
|
||||||
).read(undoServiceProvider.notifier).pushAction(freshAction);
|
.pushAction(freshAction);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('1 email(s) moved'), findsOneWidget);
|
expect(find.text('1 email(s) moved'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows correct text for delete action (moved to Trash)', (
|
testWidgets('shows correct text for delete action (moved to Trash)',
|
||||||
tester,
|
(tester) async {
|
||||||
) async {
|
when(mockUndoRepo.getHistory(limit: anyNamed('limit')))
|
||||||
when(
|
.thenAnswer((_) async => []);
|
||||||
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
|
||||||
).thenAnswer((_) async => []);
|
|
||||||
|
|
||||||
await tester.pumpWidget(buildShell(mockUndoRepo));
|
await tester.pumpWidget(buildShell(mockUndoRepo));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -93,9 +88,9 @@ void main() {
|
|||||||
emailIds: ['e1', 'e2'],
|
emailIds: ['e1', 'e2'],
|
||||||
sourceMailboxPath: 'INBOX',
|
sourceMailboxPath: 'INBOX',
|
||||||
);
|
);
|
||||||
await ProviderScope.containerOf(
|
await ProviderScope.containerOf(context)
|
||||||
context,
|
.read(undoServiceProvider.notifier)
|
||||||
).read(undoServiceProvider.notifier).pushAction(deleteAction);
|
.pushAction(deleteAction);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('2 email(s) moved to Trash'), findsOneWidget);
|
expect(find.text('2 email(s) moved to Trash'), findsOneWidget);
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Single mail view button position'), findsOneWidget);
|
expect(
|
||||||
|
find.text('Single mail view button position'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('menu position bottom option is selected by default', (
|
testWidgets('menu position bottom option is selected by default', (
|
||||||
@@ -50,9 +53,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
||||||
final menuGroup = tester.widget<RadioGroup<MenuPosition>>(
|
final menuGroup =
|
||||||
radioGroups.first,
|
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
|
||||||
);
|
|
||||||
expect(menuGroup.groupValue, MenuPosition.bottom);
|
expect(menuGroup.groupValue, MenuPosition.bottom);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,9 +70,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
||||||
final mailViewGroup = tester.widget<RadioGroup<MenuPosition>>(
|
final mailViewGroup =
|
||||||
radioGroups.last,
|
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
|
||||||
);
|
|
||||||
expect(mailViewGroup.groupValue, MenuPosition.bottom);
|
expect(mailViewGroup.groupValue, MenuPosition.bottom);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,27 +98,27 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'tapping Top in mail view button position section updates the repo',
|
'tapping Top in mail view button position section updates the repo', (
|
||||||
(tester) async {
|
tester,
|
||||||
await tester.pumpWidget(
|
) async {
|
||||||
buildApp(
|
await tester.pumpWidget(
|
||||||
initialLocation: '/accounts/preferences',
|
buildApp(
|
||||||
overrides: baseOverrides(),
|
initialLocation: '/accounts/preferences',
|
||||||
),
|
overrides: baseOverrides(),
|
||||||
);
|
),
|
||||||
await tester.pumpAndSettle();
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Top').last);
|
await tester.tap(find.text('Top').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final repo = ProviderScope.containerOf(
|
final repo = ProviderScope.containerOf(
|
||||||
tester.element(find.byType(UserPreferencesScreen)),
|
tester.element(find.byType(UserPreferencesScreen)),
|
||||||
).read(userPreferencesRepositoryProvider)
|
).read(userPreferencesRepositoryProvider)
|
||||||
as FakeUserPreferencesRepository;
|
as FakeUserPreferencesRepository;
|
||||||
|
|
||||||
expect(repo.mailViewButtonPosition, MenuPosition.top);
|
expect(repo.mailViewButtonPosition, MenuPosition.top);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets('shows after mail action section', (tester) async {
|
testWidgets('shows after mail action section', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -152,13 +153,14 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
|
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
|
||||||
final group = tester.widget<RadioGroup<AfterMailViewAction>>(
|
final group =
|
||||||
radioGroups.first,
|
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
|
||||||
);
|
|
||||||
expect(group.groupValue, AfterMailViewAction.nextMessage);
|
expect(group.groupValue, AfterMailViewAction.nextMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping Return to mailbox updates the repo', (tester) async {
|
testWidgets('tapping Return to mailbox updates the repo', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/preferences',
|
initialLocation: '/accounts/preferences',
|
||||||
|
|||||||
Reference in New Issue
Block a user