Compare commits
@@ -0,0 +1,18 @@
|
|||||||
|
# Dagger context ignore file.
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
.dart_tool/
|
||||||
|
coverage/
|
||||||
|
linux/flutter/ephemeral/
|
||||||
|
website/public/
|
||||||
|
website/resources/
|
||||||
|
.task/
|
||||||
|
.fvm/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env*
|
||||||
|
.envrc
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "SharedInbox Dev",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "../Dockerfile.dev",
|
||||||
|
"context": ".."
|
||||||
|
},
|
||||||
|
"workspaceFolder": "/src",
|
||||||
|
"workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached",
|
||||||
|
"remoteUser": "ci"
|
||||||
|
}
|
||||||
@@ -14,5 +14,7 @@ PATH_add .fvm/flutter_sdk/bin
|
|||||||
|
|
||||||
PATH_add "$HOME/Android/Sdk/platform-tools"
|
PATH_add "$HOME/Android/Sdk/platform-tools"
|
||||||
|
|
||||||
|
export DAGGER_NO_NAG=1
|
||||||
|
|
||||||
# Load variables from .env
|
# Load variables from .env
|
||||||
dotenv_if_exists
|
dotenv_if_exists
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Source: https://codeberg.org/guettli/sharedinbox/src/branch/main/.forgejo/Dockerfile
|
||||||
|
# Install at on the act-runner host on: /etc/forgejo/runner/Dockerfile
|
||||||
|
#
|
||||||
|
# In systemd service:
|
||||||
|
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||||
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
|
|
||||||
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
|
# Infrastructure tools required by CI workflows
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# SOPS
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/sops
|
||||||
|
|
||||||
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|
||||||
|
# Task runner
|
||||||
|
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||||
|
| sh -s -- -b /usr/local/bin v3.48.0
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Chaos Monkey
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
chaos-monkey-backend:
|
||||||
|
name: Chaos Monkey (backend)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
- name: Run backend chaos monkey
|
||||||
|
run: task chaos-monkey-backend
|
||||||
@@ -1,42 +1,39 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
# Match the label of your self-hosted runner
|
runs-on: ubuntu-latest
|
||||||
runs-on: self-hosted
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
- name: Enable Nix flakes
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.config/nix
|
runner_start=$(date +%s)
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
- name: Run Full Check Suite
|
- name: Run Full Check Suite
|
||||||
# Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
|
run: task check-dagger
|
||||||
# 'task check' runs analyze, unit tests, widget tests, and integration tests.
|
|
||||||
run: nix develop --command task check
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux Release
|
|
||||||
runs-on: self-hosted
|
|
||||||
needs: check
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.config/nix
|
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
- name: Build Linux
|
|
||||||
run: nix develop --command task build-linux-release
|
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *' # every hour on the hour
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Detect Changed Files
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
android: ${{ steps.diff.outputs.android }}
|
||||||
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect Android and Linux changes
|
||||||
|
id: diff
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# On workflow_dispatch always build everything
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
|
||||||
|
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
||||||
|
# (per-job records) directly and filter for the task we care about. Filtering at the
|
||||||
|
# task level also distinguishes runs where the Play Store job actually ran from runs
|
||||||
|
# where it was skipped — at the run level both show status=success.
|
||||||
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
|
import json, os, sys, urllib.request
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
for t in data.get("workflow_runs", []):
|
||||||
|
if (t.get("workflow_id") == "deploy.yml"
|
||||||
|
and t.get("name") == "Build & Deploy to Play Store"
|
||||||
|
and t.get("status") == "success"):
|
||||||
|
print(t.get("head_sha") or "")
|
||||||
|
sys.exit(0)
|
||||||
|
print("")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
|
print("")
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||||
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diff from the last successfully deployed commit to catch all changes since
|
||||||
|
# that deploy, not just the most recent commit. Deploy all targets when the
|
||||||
|
# SHA is not in local history (shallow clone or very old deploy).
|
||||||
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|
|| git show --name-only --format= HEAD)
|
||||||
|
else
|
||||||
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||||
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Android deploy: SKIPPED (no android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy-playstore:
|
||||||
|
name: Build & Deploy to Play Store
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 100
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Publish Android to Play Store
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task publish-android
|
||||||
|
|
||||||
|
- name: Verify Play Store deployment
|
||||||
|
run: |
|
||||||
|
python3 -m venv /tmp/playstore-venv
|
||||||
|
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
|
||||||
|
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
|
||||||
|
|
||||||
|
|
||||||
|
deploy-apk:
|
||||||
|
name: Build & Deploy APK to Server
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 100
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Build & Deploy APK to server
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task deploy-apk
|
||||||
|
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Build Linux Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.linux == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 100
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Build & Deploy Linux to server
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task deploy-linux
|
||||||
|
|
||||||
|
|
||||||
|
label-deploy-health:
|
||||||
|
name: Update Deploy Health Label
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [deploy-playstore, deploy-apk, build-linux]
|
||||||
|
if: |
|
||||||
|
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||||
|
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||||
|
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||||
|
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||||
|
)
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
|
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||||
|
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||||
|
run: |
|
||||||
|
python3 - << 'PYEOF'
|
||||||
|
import os, json, urllib.request, urllib.error
|
||||||
|
|
||||||
|
issue = os.environ.get("DEPLOY_HEALTH_ISSUE", "").strip()
|
||||||
|
if not issue:
|
||||||
|
print("DEPLOY_HEALTH_ISSUE not set; skipping")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||||
|
succeeded = os.environ.get("ALL_SUCCEEDED", "false").lower() == "true"
|
||||||
|
add_label = "CI/Full-Pass" if succeeded else "CI/Full-Fail"
|
||||||
|
remove_label = "CI/Full-Fail" if succeeded else "CI/Full-Pass"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
||||||
|
|
||||||
|
def api_get(path):
|
||||||
|
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def api_put(path, body):
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="PUT")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"PUT {path} failed: {e.read().decode()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
repo_labels = api_get("/labels")
|
||||||
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
|
if add_label not in label_map:
|
||||||
|
print(f"Label '{add_label}' not found in repo — create it first")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
current = api_get(f"/issues/{issue}/labels")
|
||||||
|
keep_ids = [l["id"] for l in current if l["name"] not in ("CI/Full-Pass", "CI/Full-Fail")]
|
||||||
|
keep_ids.append(label_map[add_label])
|
||||||
|
|
||||||
|
api_put(f"/issues/{issue}/labels", {"labels": keep_ids})
|
||||||
|
print(f"Set '{add_label}' on issue #{issue}")
|
||||||
|
PYEOF
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
name: Firebase Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *' # once per day at 3 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-changes:
|
||||||
|
name: Detect Firebase-Relevant Changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect Firebase-relevant changes in last 24 hours
|
||||||
|
id: diff
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# On workflow_dispatch always run
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
|
||||||
|
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
|
||||||
|
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
|
||||||
|
| sort -u | grep -v '^$')
|
||||||
|
|
||||||
|
if [ -n "$CHANGED" ]; then
|
||||||
|
echo "Firebase-relevant files changed since $SINCE:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
test-android-firebase:
|
||||||
|
name: Android Instrumented Tests (Firebase Test Lab)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
|
env:
|
||||||
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task test-android-firebase
|
||||||
|
|
||||||
|
- name: Create issue on test failure
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
FORGEJO_URL: ${{ github.server_url }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
python3 - << 'PYEOF'
|
||||||
|
import os, json, urllib.request, urllib.error
|
||||||
|
|
||||||
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||||
|
run_url = os.environ["RUN_URL"]
|
||||||
|
|
||||||
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
||||||
|
|
||||||
|
def api_get(path):
|
||||||
|
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def api_post(path, body):
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
repo_labels = api_get("/labels")
|
||||||
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
|
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
|
||||||
|
|
||||||
|
title = "Firebase Tests failed — find root cause and fix"
|
||||||
|
body = (
|
||||||
|
"Firebase instrumented tests failed in the daily run.\n\n"
|
||||||
|
f"**Failed run:** {run_url}\n\n"
|
||||||
|
"## Steps to resolve\n\n"
|
||||||
|
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
|
||||||
|
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
|
||||||
|
"3. Close this issue once the root cause is resolved and the tests pass.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
issue = api_post("/issues", {
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"labels": label_ids,
|
||||||
|
})
|
||||||
|
print(f"Created issue #{issue['number']}: {issue['html_url']}")
|
||||||
|
PYEOF
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Publish Dev Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile.dev'
|
||||||
|
- '.devcontainer/devcontainer.json'
|
||||||
|
- '.forgejo/workflows/publish-dev-container.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & Push sharedinbox-dev
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
REGISTRY: codeberg.org
|
||||||
|
IMAGE: codeberg.org/guettli/sharedinbox-dev
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Codeberg container registry
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
echo "$FORGEJO_TOKEN" \
|
||||||
|
| docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
docker build \
|
||||||
|
-t "$IMAGE:latest" \
|
||||||
|
-t "$IMAGE:$SHORT_SHA" \
|
||||||
|
-f Dockerfile.dev \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-playstore:
|
|
||||||
name: Build & Deploy to Play Store
|
|
||||||
runs-on: self-hosted
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.config/nix
|
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
- name: Install Android SDK (cached on runner between runs)
|
|
||||||
run: |
|
|
||||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
|
||||||
if [ ! -d "$SDK/platforms/android-34" ]; then
|
|
||||||
echo "Android SDK not found, installing..."
|
|
||||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
|
||||||
mkdir -p "$SDK/cmdline-tools"
|
|
||||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
|
||||||
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
|
||||||
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
|
||||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
|
||||||
else
|
|
||||||
echo "Android SDK cached, skipping install."
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Prepare Keystore
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
|
||||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
|
||||||
else
|
|
||||||
echo "Error: ANDROID_KEYSTORE_BASE64 secret is not set."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build & Deploy to Play Store
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
|
||||||
run: nix develop --command task deploy-android-bundle
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
name: Renovate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Run Renovate
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task renovate
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Deploy Website
|
name: Update Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *' # every hour on the hour
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
@@ -10,38 +12,142 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
check-changes:
|
||||||
name: Build & Deploy Website
|
name: Detect Website Changes
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Enable Nix flakes
|
- name: Detect website changes since last deploy
|
||||||
run: |
|
id: diff
|
||||||
mkdir -p ~/.config/nix
|
shell: bash
|
||||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$SSH_PRIVATE_KEY" ]; then
|
# On push or workflow_dispatch always deploy
|
||||||
mkdir -p ~/.ssh
|
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
chmod 600 ~/.ssh/id_rsa
|
exit 0
|
||||||
else
|
|
||||||
echo "Error: WEBSITE_SSH_PRIVATE_KEY secret is not set."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Deploy
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
env:
|
|
||||||
SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
|
||||||
run: nix develop --command task website-deploy
|
|
||||||
|
|
||||||
- name: Verify
|
# Find the most recent successful "Build & Update Website" task. Forgejo's API
|
||||||
run: nix develop --command task website-verify
|
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
||||||
|
# (per-job records) directly and filter for the task we care about. Filtering at the
|
||||||
|
# task level also distinguishes runs where the deploy job actually ran from runs
|
||||||
|
# where it was skipped — at the run level both show status=success.
|
||||||
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
|
import json, os, sys, urllib.request
|
||||||
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
|
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
for t in data.get("workflow_runs", []):
|
||||||
|
if (t.get("workflow_id") == "website.yml"
|
||||||
|
and t.get("name") == "Build & Update Website"
|
||||||
|
and t.get("status") == "success"):
|
||||||
|
print(t.get("head_sha") or "")
|
||||||
|
sys.exit(0)
|
||||||
|
print("")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
|
print("")
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::warning::Could not determine last successfully deployed SHA — deploying as a precaution"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::notice::Website deploy SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Diff from last successfully deployed commit to catch all changes since
|
||||||
|
# that deploy, not just the most recent commit.
|
||||||
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|
|| git show --name-only --format= HEAD)
|
||||||
|
else
|
||||||
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying as a precaution"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
website_re='^(website/|scripts/website-verify\.sh|\.forgejo/workflows/website\.yml)'
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -qE "$website_re"; then
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Website deploy TRIGGERED — website-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Website deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no website-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Build & Update Website
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
|
- name: Build & Update Website
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
run: task publish-website
|
||||||
|
|
||||||
|
- name: Verify Website
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
||||||
|
run: scripts/website-verify.sh
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Windows Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-nightly:
|
||||||
|
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||||
|
name: Build & Deploy Windows (Nightly)
|
||||||
|
runs-on: windows-runner
|
||||||
|
timeout-minutes: 90
|
||||||
|
if: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check for recent changes on main
|
||||||
|
run: |
|
||||||
|
$changes = git log --oneline --since "24 hours ago" origin/main
|
||||||
|
if (-not $changes) {
|
||||||
|
Write-Output "No changes in last 24 hours, skipping build."
|
||||||
|
Add-Content -Path $env:GITHUB_ENV -Value "SKIP_BUILD=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
if: env.SKIP_BUILD != 'true'
|
||||||
|
run: task build-windows-release
|
||||||
|
|
||||||
|
- name: Set up SSH key
|
||||||
|
if: env.SKIP_BUILD != 'true'
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p $env:USERPROFILE\.ssh
|
||||||
|
$env:SSH_PRIVATE_KEY | Out-File -FilePath "$env:USERPROFILE\.ssh\id_rsa" -Encoding ascii
|
||||||
|
icacls "$env:USERPROFILE\.ssh\id_rsa" /inheritance:r /grant:r "$env:USERNAME:F"
|
||||||
|
|
||||||
|
- name: Deploy Windows to server
|
||||||
|
if: env.SKIP_BUILD != 'true'
|
||||||
|
env:
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
run: task deploy-windows-to-server
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze-and-test:
|
|
||||||
name: Analyze & unit test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: dart format --set-exit-if-changed .
|
|
||||||
|
|
||||||
- name: Analyze
|
|
||||||
run: flutter analyze --fatal-infos
|
|
||||||
|
|
||||||
- name: Unit + widget tests with coverage
|
|
||||||
run: flutter test test/unit/ test/widget/ --coverage
|
|
||||||
|
|
||||||
- name: Coverage gate
|
|
||||||
run: dart run scripts/check_coverage.dart
|
|
||||||
|
|
||||||
integration:
|
|
||||||
name: Integration tests (Stalwart)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Run integration tests only on push to main, not on every PR.
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
integration-ui:
|
|
||||||
name: UI Integration tests (Stalwart + Xvfb)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Install Flutter Linux build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Cache Linux debug build
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
build/linux
|
|
||||||
.dart_tool/flutter_build
|
|
||||||
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
|
|
||||||
restore-keys: linux-debug-
|
|
||||||
|
|
||||||
- name: Run UI integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/integration_ui_test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux desktop
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: analyze-and-test
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install GTK3, build tools and libsecret
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Build Linux release
|
|
||||||
run: flutter build linux --release
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# --- Flutter/Dart ---
|
# --- Flutter/Dart ---
|
||||||
coverage/
|
coverage/
|
||||||
|
screenshots/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
pubspec.lock
|
|
||||||
build/
|
build/
|
||||||
*.g.dart
|
*.g.dart
|
||||||
*.freezed.dart
|
*.freezed.dart
|
||||||
@@ -29,7 +29,8 @@ android/.gradle/
|
|||||||
android/local.properties
|
android/local.properties
|
||||||
android/app/google-services.json
|
android/app/google-services.json
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/app/src/main/java/io/flutter/plugins/
|
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
||||||
|
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
||||||
.android/
|
.android/
|
||||||
Android/
|
Android/
|
||||||
.gradle/
|
.gradle/
|
||||||
@@ -58,6 +59,10 @@ linux/flutter/generated_plugins.cmake
|
|||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.metadata
|
.metadata
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# --- Tools & Cache ---
|
# --- Tools & Cache ---
|
||||||
.fvm/
|
.fvm/
|
||||||
fvm/
|
fvm/
|
||||||
@@ -98,6 +103,8 @@ sharedinbox-runner/runner-data/
|
|||||||
website/public/
|
website/public/
|
||||||
website/resources/
|
website/resources/
|
||||||
website/.hugo_build.lock
|
website/.hugo_build.lock
|
||||||
|
website/content/builds/_index.md
|
||||||
|
website/content/builds/[0-9]*/
|
||||||
|
|
||||||
.copilot/
|
.copilot/
|
||||||
.dotnet/
|
.dotnet/
|
||||||
@@ -105,4 +112,15 @@ website/.hugo_build.lock
|
|||||||
.wget-hsts
|
.wget-hsts
|
||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
|
test/widget/failures/
|
||||||
.claude*
|
.claude*
|
||||||
|
|
||||||
|
dagger-certs
|
||||||
|
.Xauthority
|
||||||
|
.sharedinbox-agent-state.json
|
||||||
|
|
||||||
|
.viminfo
|
||||||
|
/go
|
||||||
|
.last_deployed_sha
|
||||||
|
.fail_count
|
||||||
|
/*.kubeconfig
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "website/themes/PaperMod"]
|
|
||||||
path = website/themes/PaperMod
|
|
||||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
|
||||||
@@ -10,17 +10,52 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: https://github.com/guettli/sync-branch
|
||||||
|
rev: v0.0.11
|
||||||
|
hooks:
|
||||||
|
- id: sync-branch
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: check-no-binary
|
||||||
|
name: check for binary files (build artifacts, databases)
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_no_binary.sh'
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
- id: forbidden-files-hook
|
- id: forbidden-files-hook
|
||||||
name: check for forbidden home-directory files
|
name: check for forbidden home-directory files
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
- id: ci-no-direct-dagger
|
||||||
|
name: check for direct dagger calls in workflows (use Task instead)
|
||||||
|
language: system
|
||||||
|
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
- id: dagger-progress-plain
|
||||||
|
name: ensure all dagger calls use --progress=plain
|
||||||
|
language: system
|
||||||
|
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
- id: ci-image-exists
|
||||||
|
name: verify container images in ci/main.go are reachable
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
- id: dagger-versions-aligned
|
||||||
|
name: verify Dagger version is consistent across dagger.json, Dockerfile and DAGGER.md
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/dagger\.json|\.forgejo/Dockerfile|DAGGER\.md)$
|
||||||
|
|||||||
@@ -1,5 +1,53 @@
|
|||||||
# SharedInbox — Development Guide
|
# SharedInbox — Development Guide
|
||||||
|
|
||||||
|
## Codeberg
|
||||||
|
|
||||||
|
We use Codeberg: https://codeberg.org/guettli/sharedinbox/
|
||||||
|
|
||||||
|
CLI tool `fgj` is available to query issues/PRs/actions.
|
||||||
|
|
||||||
|
## Issue Label Workflow
|
||||||
|
|
||||||
|
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
||||||
|
|
||||||
|
| Label | Trigger | Outcome |
|
||||||
|
|---|---|---|
|
||||||
|
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||||
|
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
|
||||||
|
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
|
||||||
|
|
||||||
|
**State machine:**
|
||||||
|
|
||||||
|
```
|
||||||
|
loop/plan → loop/plan-in-process → loop/plan-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
|
loop/code → loop/code-in-process → loop/merge (via route)
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
|
loop/merge → loop/merge-in-process → loop/merge-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||||
|
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||||
|
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
|
||||||
|
- 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:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Create issue
|
||||||
|
2. Add label loop/plan → agent writes plan as comment
|
||||||
|
3. Review plan, request changes or approve
|
||||||
|
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
||||||
|
5. (Optional) Review PR before it merges
|
||||||
|
6. Merge agent waits for CI and merges the PR automatically
|
||||||
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- Avoid `else`, use "early return".
|
- Avoid `else`, use "early return".
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Dagger CI/CD Setup
|
||||||
|
|
||||||
|
This project has migrated from Taskfile-based CI to **Dagger**. This document explains the infrastructure setup for the shared Dagger Server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
We use a **Shared Dagger Server** approach for both local development and CI. This allows multiple users to share a single Dagger Engine and its cache, significantly speeding up builds.
|
||||||
|
|
||||||
|
- **Container Engine:** Rootless Podman (managed by the `dagger-svc` user).
|
||||||
|
- **Orchestration:** System-wide `systemd` service.
|
||||||
|
- **Access:** Users connect via TCP (localhost) or Unix Socket.
|
||||||
|
|
||||||
|
## Server Setup (Admin)
|
||||||
|
|
||||||
|
### 1. Dedicated Service User
|
||||||
|
A dedicated user `dagger-svc` owns the Dagger Engine and its cache.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -m -s /bin/bash dagger-svc
|
||||||
|
sudo loginctl enable-linger dagger-svc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Lingering?**
|
||||||
|
Lingering is required for rootless users to maintain a persistent background session. It ensures that `/run/user/<UID>` and the user-level Dagger/Podman namespaces are initialized at boot and remain active even when the user is not logged in.
|
||||||
|
|
||||||
|
### 2. Systemd Service
|
||||||
|
The engine is managed by a system-wide systemd service located at `/etc/systemd/system/dagger-engine.service`.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Dagger Engine (Shared Server)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=dagger-svc
|
||||||
|
Group=dagger-svc
|
||||||
|
WorkingDirectory=/home/dagger-svc
|
||||||
|
# Replace 1003 with the actual UID of dagger-svc
|
||||||
|
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
||||||
|
ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
To connect to the shared engine, users should set the `_DAGGER_RUNNER_HOST` environment variable.
|
||||||
|
|
||||||
|
### Local Development (.env)
|
||||||
|
The project uses a `.env` file to manage the connection string. Ensure your `.env` contains:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
_DAGGER_RUNNER_HOST=tcp://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
Once the environment is set up, you can run the Dagger pipeline. For non-interactive environments (CI, LLMs), use `--progress=plain` for readable logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop --command dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
All sensitive credentials are passed as `dagger.Secret` (never as plain strings).
|
||||||
|
This prevents values from appearing in Dagger logs or being cached in the engine.
|
||||||
|
|
||||||
|
| Parameter | Functions |
|
||||||
|
|---|---|
|
||||||
|
| `sshKey *dagger.Secret` | `Deployer`, `GenerateBuildHistory`, `BuildWebsite`, `PublishWebsite`, `DeployLinux`, `DeployApk` |
|
||||||
|
| `keystoreBase64 *dagger.Secret` | `setupKeystore`, `BuildAndroidApk`, `DeployApk`, `SignAndroidBundle`, `PublishAndroid` |
|
||||||
|
| `keystorePassword *dagger.Secret` | same as above |
|
||||||
|
| `playStoreConfig *dagger.Secret` | `UploadToPlayStore`, `PublishAndroid` |
|
||||||
|
| `serviceAccountKey *dagger.Secret` | `TestAndroidFirebase` |
|
||||||
|
|
||||||
|
Secrets are injected via `WithMountedSecret` (file-based, e.g. SSH key) or
|
||||||
|
`WithSecretVariable` (env-var-based, e.g. keystore data, Play Store JSON).
|
||||||
|
|
||||||
|
The only credentials not typed as `dagger.Secret` are the test passwords
|
||||||
|
(`STALWART_PASS_B`, `STALWART_PASS_C`) in `WithStalwart`. These are hardcoded
|
||||||
|
development values defined in `stalwart-dev/` — not production secrets.
|
||||||
|
|
||||||
|
## CI Integration (Codeberg/Forgejo)
|
||||||
|
|
||||||
|
The CI workflow in `.forgejo/workflows/ci.yml` is configured to use the Dagger module located in the `ci/` directory.
|
||||||
|
|
||||||
|
- **Check Suite:** Runs analysis and tests in parallel.
|
||||||
|
- **Builds:** Produces Linux and Android artifacts.
|
||||||
|
- **Caching:** When using the shared engine, CI runners benefit from the persistent cache on the host.
|
||||||
|
|
||||||
|
## Credential Security — Keeping Production Secrets Off Codeberg
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The current setup stores two categories of secrets in Codeberg repository secrets:
|
||||||
|
|
||||||
|
1. **Dagger access credentials** — TLS certificates used to connect to the remote Dagger engine via stunnel (`DAGGER_CA_CERT`, `DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`, `DAGGER_STUNNEL_URL`).
|
||||||
|
2. **Production secrets** — actual credentials for external services: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `PLAY_STORE_CONFIG_JSON`, `SSH_PRIVATE_KEY`, `FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY`.
|
||||||
|
|
||||||
|
If Codeberg is compromised, both categories are leaked. The Dagger TLS certificates enable access only to the Dagger engine and have limited blast radius. But the production secrets give direct access to the Play Store, the Android signing key, the deployment server, and Firebase — a much larger blast radius.
|
||||||
|
|
||||||
|
**Goal:** Keep only Dagger access credentials in Codeberg. Store all production secrets on the Dagger host machine so they never touch Codeberg.
|
||||||
|
|
||||||
|
### Option 1: Runner-level environment variables
|
||||||
|
|
||||||
|
Store production secrets as environment variables in the Forgejo runner's systemd service (e.g., via a `EnvironmentFile=` in the service override). The runner injects host env vars into job processes automatically. CI workflows drop the `${{ secrets.XYZ }}` references for production secrets entirely — the variables are already present in the job environment.
|
||||||
|
|
||||||
|
**Pro:**
|
||||||
|
- No new infrastructure required.
|
||||||
|
- Works with the existing `dagger call --progress=plain --secret env:VAR_NAME` argument style.
|
||||||
|
- Secrets never enter Codeberg.
|
||||||
|
- Straightforward to set up on a single self-hosted runner.
|
||||||
|
|
||||||
|
**Con:**
|
||||||
|
- Env vars are visible to every process on the runner host (e.g., via `/proc/<pid>/environ`).
|
||||||
|
- Rotating a secret requires host access (no API).
|
||||||
|
- Does not scale cleanly to multiple runners without a shared secrets mechanism.
|
||||||
|
|
||||||
|
### Option 2: Secret files on the CI host with restricted permissions
|
||||||
|
|
||||||
|
Store production secrets as files owned by the runner user with mode `600` (e.g., `/home/forgejo-runner/secrets/play_store.json`). A small setup script reads the files and either exports them as env vars or passes them directly as file-type arguments to `dagger call --progress=plain`. CI workflows contain no secret references at all.
|
||||||
|
|
||||||
|
**Pro:**
|
||||||
|
- OS-level file permissions limit access to the runner user.
|
||||||
|
- Natural format for JSON payloads and key files.
|
||||||
|
- Easy to audit (list files, check mtime).
|
||||||
|
- No new infrastructure.
|
||||||
|
|
||||||
|
**Con:**
|
||||||
|
- Plaintext files on disk; root or backup access exposes them.
|
||||||
|
- Workflow must know file paths (either hardcoded or by convention).
|
||||||
|
- Rotation still requires host filesystem access.
|
||||||
|
|
||||||
|
### Option 3: Dagger host as pipeline orchestrator
|
||||||
|
|
||||||
|
Instead of the CI runner invoking the Dagger CLI directly, the CI job sends a trigger to the Dagger host over SSH. The Dagger host runs the pipeline locally against its own environment, where secrets live as env vars or files. Codeberg only stores the SSH key to reach the Dagger host — not the production secrets.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# CI job only does this:
|
||||||
|
- name: Trigger pipeline on Dagger host
|
||||||
|
run: ssh dagger-host "cd sharedinbox && task publish-android"
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.DAGGER_TRIGGER_SSH_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pro:**
|
||||||
|
- Production secrets never leave the Dagger host.
|
||||||
|
- Codeberg stores exactly one secret: the trigger SSH key.
|
||||||
|
- All deployment logic and secrets are fully contained on the host.
|
||||||
|
|
||||||
|
**Con:**
|
||||||
|
- Harder to stream structured CI logs back to Codeberg Actions.
|
||||||
|
- Dynamic context (commit SHA, PR branch) must be passed explicitly over SSH.
|
||||||
|
- The trigger SSH key still grants shell access to the host, so its compromise has its own blast radius.
|
||||||
|
- CI becomes a "fire-and-forget" call, making failure attribution harder.
|
||||||
|
|
||||||
|
### Option 4: External secret manager (e.g., HashiCorp Vault)
|
||||||
|
|
||||||
|
Run a secret manager co-located with the Dagger host. The CI job authenticates with a short-lived AppRole credential (stored in Codeberg) and retrieves secrets at runtime. Vault can also be configured with IP-allow-lists to further restrict who can authenticate.
|
||||||
|
|
||||||
|
**Pro:**
|
||||||
|
- Full audit trail: every secret read is logged with a timestamp and caller identity.
|
||||||
|
- Fine-grained access control per secret.
|
||||||
|
- Built-in versioning and rotation support.
|
||||||
|
- Industry-standard approach; scales to team or multi-runner setups.
|
||||||
|
|
||||||
|
**Con:**
|
||||||
|
- Significant additional infrastructure to install, configure, and maintain.
|
||||||
|
- Vault credentials (RoleID + SecretID) still need to be in Codeberg, though with a smaller blast radius than raw secrets.
|
||||||
|
- Vault itself becomes a security-critical single point of failure.
|
||||||
|
- Operational overhead likely disproportionate for a small single-developer project.
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
|
||||||
|
|
||||||
|
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
|
||||||
|
|
||||||
|
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Development Environment Setup
|
||||||
|
|
||||||
|
This document explains how to set up a development environment for SharedInbox.
|
||||||
|
|
||||||
|
## ⚠️ Security Recommendation: Use a Dedicated Linux User
|
||||||
|
|
||||||
|
For enhanced security, especially when working with autonomous coding agents (like Gemini CLI in YOLO mode), we **strongly recommend** using a dedicated Linux user for this project. This isolates the project environment and prevents any potential accidental damage to your main system.
|
||||||
|
|
||||||
|
### 1. Create a Dedicated User
|
||||||
|
|
||||||
|
Set the user name variable (default is `si` for SharedInbox):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DEV_USER=si
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the user and add them to the `sudo` group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser --disabled-password newuser $DEV_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up SSH public key login (replace with your actual public key):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /home/$DEV_USER/.ssh
|
||||||
|
sudo chmod 700 /home/$DEV_USER/.ssh
|
||||||
|
echo "ssh-ed25519 AAAA... your-key-comment" | sudo tee /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Switch to the Dedicated User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh $DEV_USER@localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create ssh-keypair
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Clone the Repository
|
||||||
|
|
||||||
|
Clone the project into your new user's home directory:
|
||||||
|
|
||||||
|
```bash^
|
||||||
|
git clone ssh://git@codeberg.org/guettli/sharedinbox.git
|
||||||
|
|
||||||
|
# Move git directory into $HOME
|
||||||
|
# This user only works on the git repo. Avoid "cd sharedinbox" after each login...
|
||||||
|
mv sharedinbox/* .
|
||||||
|
mv sharedinbox/.??* .
|
||||||
|
rmdir sharedinbox/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3b. Configure Git Identity
|
||||||
|
|
||||||
|
The new user needs a Git identity for commits and some scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global user.name "Your Name"
|
||||||
|
git config --global user.email "your.email@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Install System Dependencies
|
||||||
|
|
||||||
|
This project uses **Nix** with flakes to manage its toolchain (Flutter, Dart, Stalwart, etc.).
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir -p .config/nix
|
||||||
|
|
||||||
|
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||||
|
|
||||||
|
nix profile add nixpkgs#direnv
|
||||||
|
|
||||||
|
nix profile add nixpkgs#nix-direnv
|
||||||
|
|
||||||
|
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
.config/direnv/direnv.toml
|
||||||
|
```
|
||||||
|
[global]
|
||||||
|
hide_env_diff = true
|
||||||
|
#log_filter = "^$"
|
||||||
|
|
||||||
|
[whitelist]
|
||||||
|
prefix = [ "/home/DEV_USER-CHANGE_THAT" ]
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 4b. Additional Permissions (GUI & Android)
|
||||||
|
|
||||||
|
1. **GUI Access**: To run the Linux app (`task run`) from the `si` user, you must allow it to access your X server. Run this **from your main user terminal**:
|
||||||
|
```bash
|
||||||
|
xhost +local:$DEV_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Android Emulator (KVM)**: If you plan to use the Android emulator, add the user to the `kvm` group:
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG kvm $DEV_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Project Setup
|
||||||
|
|
||||||
|
Once you are in the project directory and have the dependencies installed:
|
||||||
|
|
||||||
|
1. **Initialize Environment**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Allow direnv**:
|
||||||
|
```bash
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
*This will trigger Nix to download and set up the environment (Flutter, Android SDK, etc.). It might take some time on the first run.*
|
||||||
|
|
||||||
|
3. **Install Flutter (via FVM)**:
|
||||||
|
Nix provides FVM, which manages the pinned Flutter version.
|
||||||
|
```bash
|
||||||
|
fvm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Initial Setup**:
|
||||||
|
Run the comprehensive setup command which handles `pub get`, code generation, and git hooks:
|
||||||
|
```bash
|
||||||
|
task setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Verify the Setup
|
||||||
|
|
||||||
|
Run the full check suite to ensure everything is working correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task check
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Running the App
|
||||||
|
|
||||||
|
To run the app on your Linux desktop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with VS Code
|
||||||
|
|
||||||
|
To maintain isolation, it is recommended to run VS Code "remotely" on the dedicated development user.
|
||||||
|
|
||||||
|
### Preferred Method: VS Code Remote - SSH
|
||||||
|
|
||||||
|
The most robust way to work with a separate user is using the **VS Code Remote - SSH** extension. This allows you to run the VS Code Server as the `si` user while using your main user's GUI.
|
||||||
|
|
||||||
|
1. **Install the Extension**: Install "Remote - SSH" from the VS Code Marketplace.
|
||||||
|
2. **Enable SSH for the Dev User**:
|
||||||
|
From your main user, copy your SSH public key to the dev user:
|
||||||
|
```bash
|
||||||
|
# As your main user:
|
||||||
|
sudo mkdir -p /home/$DEV_USER/.ssh
|
||||||
|
sudo cp ~/.ssh/id_rsa.pub /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
sudo chown -R $DEV_USER:$DEV_USER /home/$DEV_USER/.ssh
|
||||||
|
sudo chmod 700 /home/$DEV_USER/.ssh
|
||||||
|
sudo chmod 600 /home/$DEV_USER/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
3. **Connect**:
|
||||||
|
In VS Code, open the Command Palette (`Ctrl+Shift+P`) and select `Remote-SSH: Connect to Host...`.
|
||||||
|
Enter: `si@localhost` (or `$DEV_USER@localhost`).
|
||||||
|
4. **Install Extensions in the Remote**:
|
||||||
|
Once connected, you will need to install the following extensions *on the remote user*:
|
||||||
|
* **Dart** / **Flutter**
|
||||||
|
* **direnv**: (by mkhl) Highly recommended to automatically load the Nix environment inside VS Code.
|
||||||
|
* **Nix IDE**: For syntax highlighting.
|
||||||
|
|
||||||
|
### Why SSH?
|
||||||
|
Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks. It provides a clean boundary for the VS Code process and any integrated terminal or coding agents, ensuring they cannot access your personal files in `/home/$YOUR_USER`.
|
||||||
|
|
||||||
|
> **Note on Security:** While these instructions add the user to the `sudo` group for convenience during setup, you can remove it later with `sudo gpasswd -d $DEV_USER sudo` to further restrict the user and any coding agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily Workflow
|
||||||
|
|
||||||
|
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||||
|
|
||||||
|
<!-- agentloop code test passed -->
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Development and Testing Container for SharedInbox
|
||||||
|
# Replaces the Nix shell environment.
|
||||||
|
FROM ghcr.io/cirruslabs/flutter:3.44.0
|
||||||
|
|
||||||
|
# Install Linux desktop build and test dependencies, Go, NodeJS, python3, and utilities
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
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 \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
jq \
|
||||||
|
python3-pip \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
hugo \
|
||||||
|
lcov \
|
||||||
|
rsync \
|
||||||
|
openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Task runner
|
||||||
|
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||||
|
| sh -s -- -b /usr/local/bin v3.48.0
|
||||||
|
|
||||||
|
# Install Dagger CLI
|
||||||
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|
||||||
|
# Install python packages (Play Store API clients + pre-commit)
|
||||||
|
RUN pip install --break-system-packages --no-cache-dir \
|
||||||
|
google-api-python-client \
|
||||||
|
google-auth-httplib2 \
|
||||||
|
httplib2 \
|
||||||
|
pre-commit==4.5.1
|
||||||
|
|
||||||
|
# Install acpx CLI globally
|
||||||
|
RUN npm install -g acpx@0.10.0
|
||||||
|
|
||||||
|
# Setup user "ci"
|
||||||
|
RUN useradd -m -s /bin/bash ci
|
||||||
|
USER ci
|
||||||
|
ENV HOME=/home/ci
|
||||||
|
ENV PATH=/home/ci/.pub-cache/bin:$PATH
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Implementation Plan: Secure WebView for HTML Emails (#21)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
|
|
||||||
|
|
||||||
## 1. Dependency Management
|
|
||||||
- **Core**: `webview_flutter` (v4+)
|
|
||||||
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
|
|
||||||
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
|
|
||||||
|
|
||||||
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
|
|
||||||
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
|
|
||||||
|
|
||||||
### Configuration & Hardening
|
|
||||||
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
|
|
||||||
- **Background**: Match the application theme (e.g., transparent or surface color).
|
|
||||||
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
|
|
||||||
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
|
|
||||||
|
|
||||||
### Image Blocking Logic
|
|
||||||
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
|
|
||||||
- **Toggle Mechanism**:
|
|
||||||
- Provide a "Load Remote Images" button in the Flutter UI.
|
|
||||||
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
|
|
||||||
|
|
||||||
### Link Interception & Phishing Protection
|
|
||||||
- Implement `NavigationDelegate.onNavigationRequest`.
|
|
||||||
- **Process**:
|
|
||||||
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
|
|
||||||
2. Block the navigation in the WebView.
|
|
||||||
3. Trigger a Flutter `showDialog` for confirmation.
|
|
||||||
- **Phishing Protection Dialog**:
|
|
||||||
- Show the full URL.
|
|
||||||
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
|
|
||||||
- Example: `https://`**`important-bank.com`**`/login`
|
|
||||||
- "Open in Browser" button uses `url_launcher`.
|
|
||||||
|
|
||||||
## 3. Integration Plan
|
|
||||||
### Step 1: Initialization
|
|
||||||
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
|
|
||||||
|
|
||||||
### Step 2: Replace Renderer in Screens
|
|
||||||
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
|
||||||
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
|
||||||
- Remove `flutter_html` imports and dependencies once migration is complete.
|
|
||||||
|
|
||||||
## 4. Verification & Security Audit
|
|
||||||
- **Manual Tests**:
|
|
||||||
- Open emails with complex HTML layouts.
|
|
||||||
- Verify images are blocked initially.
|
|
||||||
- Verify "Load images" works.
|
|
||||||
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
|
|
||||||
- **Security Check**:
|
|
||||||
- Verify that `<script>` tags are not executed.
|
|
||||||
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
|
|
||||||
|
|
||||||
## 5. Potential Challenges
|
|
||||||
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
|
|
||||||
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Snooze Feature Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
|
||||||
|
|
||||||
## Technical Approach
|
|
||||||
|
|
||||||
### 1. Metadata Storage (Account Sync)
|
|
||||||
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
|
||||||
- **JMAP:** Use `keywords`.
|
|
||||||
- **IMAP:** Use User Flags (keywords).
|
|
||||||
|
|
||||||
### 2. Database Changes
|
|
||||||
- **Migration v22:**
|
|
||||||
- `Emails` table:
|
|
||||||
- `snoozedUntil` (DateTime, nullable)
|
|
||||||
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
|
||||||
- Index on `snoozedUntil`.
|
|
||||||
|
|
||||||
### 3. Repository Updates (`EmailRepository`)
|
|
||||||
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
|
||||||
- Optimistically update local DB.
|
|
||||||
- Enqueue `snooze` change.
|
|
||||||
- New method: `Future<int> wakeUpEmails(String accountId)`
|
|
||||||
- Find local rows where `snoozedUntil <= now`.
|
|
||||||
- Enqueue `move` back to original mailbox.
|
|
||||||
- Clear snooze metadata.
|
|
||||||
|
|
||||||
### 4. Sync Loop Integration
|
|
||||||
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
|
||||||
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
|
||||||
|
|
||||||
### 5. UI Implementation
|
|
||||||
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
|
||||||
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
|
||||||
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
1. [ ] Database migration and model updates.
|
|
||||||
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
|
||||||
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
|
||||||
4. [ ] Update sync logic to parse snooze keywords.
|
|
||||||
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
|
||||||
6. [ ] UI: Snooze picker dialog.
|
|
||||||
7. [ ] UI: Add Snooze action to list and detail screens.
|
|
||||||
8. [ ] Testing and validation.
|
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Email Sync Architecture
|
||||||
|
|
||||||
|
This document describes the full lifecycle of an email action — from the moment the user taps
|
||||||
|
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
|
||||||
|
queue, exponential backoff, and the undo/cancel mechanism.
|
||||||
|
|
||||||
|
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Components
|
||||||
|
|
||||||
|
| Component | File | Role |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
|
||||||
|
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
|
||||||
|
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
|
||||||
|
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
|
||||||
|
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
|
||||||
|
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
|
||||||
|
|
||||||
|
```
|
||||||
|
User taps "Mark as read"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EmailRepository.setFlag(id, seen: true)
|
||||||
|
│
|
||||||
|
├─ 1. Write optimistic update to local DB
|
||||||
|
│ emails.is_seen = true
|
||||||
|
│
|
||||||
|
└─ 2. Insert row into pending_changes
|
||||||
|
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
|
||||||
|
(IMAP: includes uid + mailboxPath for the STORE command)
|
||||||
|
(JMAP: includes just the flag map for Email/set)
|
||||||
|
|
||||||
|
[UI immediately reflects the change via Drift's reactive streams]
|
||||||
|
|
||||||
|
│
|
||||||
|
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
|
||||||
|
_SyncLoop._flush() / flushPendingChanges()
|
||||||
|
│
|
||||||
|
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
|
||||||
|
│
|
||||||
|
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
|
||||||
|
If stateMismatch → clear checkpoint → full re-sync
|
||||||
|
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
pending_changes row deleted on success
|
||||||
|
(on permanent error: retry count incremented; evicted after 5 failures)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. IMAP sync loop
|
||||||
|
|
||||||
|
The IMAP loop runs one coroutine per account (`_AccountSync`):
|
||||||
|
|
||||||
|
```
|
||||||
|
start()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[forever loop]
|
||||||
|
├─ flushPendingChanges() ← drain outbound queue first
|
||||||
|
├─ syncMailboxes() ← detect new/removed mailboxes
|
||||||
|
├─ for each mailbox:
|
||||||
|
│ syncEmails() ← incremental: fetch only UIDs > lastUid
|
||||||
|
│ deletion reconciliation: remove rows
|
||||||
|
│ whose UID is absent from the server
|
||||||
|
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
|
||||||
|
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
|
||||||
|
│ or syncNow() signal from UI
|
||||||
|
└─ repeat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incremental sync checkpoint** — `sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
|
||||||
|
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
||||||
|
folder is re-scanned and the checkpoint is reset.
|
||||||
|
|
||||||
|
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
|
||||||
|
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JMAP sync loop
|
||||||
|
|
||||||
|
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
|
||||||
|
|
||||||
|
```
|
||||||
|
start()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[forever loop]
|
||||||
|
├─ flushPendingChanges() ← Email/set for queued mutations
|
||||||
|
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
|
||||||
|
├─ for each mailbox:
|
||||||
|
│ syncEmails() ← Email/query + Email/get (first run)
|
||||||
|
│ Email/changes (subsequent runs, state token)
|
||||||
|
└─ _wait()
|
||||||
|
├─ If server advertises eventSourceUrl: subscribe to SSE push
|
||||||
|
│ wake on "Email" change event
|
||||||
|
└─ Otherwise: sleep 30 s (poll fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
|
||||||
|
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
|
||||||
|
a full re-fetch.
|
||||||
|
|
||||||
|
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
|
||||||
|
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Exponential backoff
|
||||||
|
|
||||||
|
Both loops share the same backoff policy:
|
||||||
|
|
||||||
|
| Outcome | Backoff |
|
||||||
|
|---------|---------|
|
||||||
|
| Sync succeeded | Reset to 5 s |
|
||||||
|
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
|
||||||
|
|
||||||
|
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
|
||||||
|
on the next successful cycle.
|
||||||
|
|
||||||
|
The last error message is written to `sync_log` and surfaced in the UI via
|
||||||
|
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pending-change queue
|
||||||
|
|
||||||
|
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
|
||||||
|
reaches the server:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `id` | Auto-increment primary key |
|
||||||
|
| `email_id` | The email being mutated |
|
||||||
|
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
|
||||||
|
| `payload` | JSON-encoded protocol-specific arguments |
|
||||||
|
| `retry_count` | Incremented on each failed flush attempt |
|
||||||
|
| `created_at` | For ordering and debug |
|
||||||
|
|
||||||
|
**Optimistic UI** — every mutation writes the local change first, then inserts into
|
||||||
|
`pending_changes`. The Drift reactive stream delivers the update to the UI before
|
||||||
|
the network round-trip completes.
|
||||||
|
|
||||||
|
**Conflict resolution** — the server always wins. On the next sync cycle the server's
|
||||||
|
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
|
||||||
|
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
|
||||||
|
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Undo and cancel
|
||||||
|
|
||||||
|
When the user triggers an undoable action the UI calls:
|
||||||
|
```
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
|
||||||
|
A `SnackBar` with an **Undo** button appears for a few seconds.
|
||||||
|
|
||||||
|
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. cancelPendingChange(id, originalType)
|
||||||
|
└─ Deletes the pending_changes row if it has not been flushed yet.
|
||||||
|
Returns true if cancelled, false if the server already processed it.
|
||||||
|
|
||||||
|
2. If the email row was hard-deleted (DELETE action):
|
||||||
|
restoreEmails([original])
|
||||||
|
└─ Re-inserts the row with its pre-deletion state,
|
||||||
|
placed in the correct mailbox (source if cancelled, dest otherwise).
|
||||||
|
|
||||||
|
3. moveEmail(id, sourceMailboxPath)
|
||||||
|
└─ Optimistic local move back to the original folder.
|
||||||
|
If step 1 returned false (already sent to server), this enqueues
|
||||||
|
a reverse-move in pending_changes so the server move is undone too.
|
||||||
|
|
||||||
|
4. If step 1 returned true (cancelled before flush):
|
||||||
|
cancelPendingChange(id, 'move')
|
||||||
|
└─ The reverse-move from step 3 is redundant; remove it.
|
||||||
|
```
|
||||||
|
|
||||||
|
The net result is: if the mutation was still in the queue it is silently cancelled with no
|
||||||
|
server round-trip; if it had already been flushed, a compensating move is queued.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Key invariants
|
||||||
|
|
||||||
|
- **Order**: pending changes are flushed before syncing. This prevents the server from
|
||||||
|
overwriting an optimistic local state that the server hasn't seen yet.
|
||||||
|
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
|
||||||
|
deleted only after the server acknowledges the change.
|
||||||
|
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
|
||||||
|
visible in the UI (Settings → Failed mutations).
|
||||||
|
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
|
||||||
|
through `core/` interfaces. The `check-layers` Taskfile task enforces this.
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
version: "3"
|
version: "3"
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DAGGER_NO_NAG: "1"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
default:
|
default:
|
||||||
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
||||||
@@ -34,6 +37,8 @@ tasks:
|
|||||||
run: once
|
run: once
|
||||||
deps: [_nix-check]
|
deps: [_nix-check]
|
||||||
preconditions:
|
preconditions:
|
||||||
|
- sh: '[ "$(id -u)" != "0" ]'
|
||||||
|
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
|
||||||
- sh: test -n "${IN_NIX_SHELL}"
|
- sh: test -n "${IN_NIX_SHELL}"
|
||||||
msg: "Not in nix dev shell. Run: nix develop"
|
msg: "Not in nix dev shell. Run: nix develop"
|
||||||
cmds:
|
cmds:
|
||||||
@@ -53,6 +58,14 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- echo "Setup complete."
|
- echo "Setup complete."
|
||||||
|
|
||||||
|
generate-icons:
|
||||||
|
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||||
|
deps: [_pub-get]
|
||||||
|
cmds:
|
||||||
|
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||||
|
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||||
|
- fvm flutter pub run flutter_launcher_icons
|
||||||
|
|
||||||
generate-changelog:
|
generate-changelog:
|
||||||
desc: Generate assets/changelog.txt from git history
|
desc: Generate assets/changelog.txt from git history
|
||||||
cmds:
|
cmds:
|
||||||
@@ -93,43 +106,34 @@ tasks:
|
|||||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
desc: Generate Drift DB code (run after any schema change)
|
desc: Generate Drift DB code via Dagger (exports generated files back to host)
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
||||||
|
|
||||||
|
analyze:
|
||||||
|
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. analyze
|
||||||
|
|
||||||
|
format:
|
||||||
|
desc: Format all Dart source files via Dagger (writes back to host)
|
||||||
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
||||||
|
|
||||||
|
check-mocks:
|
||||||
|
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||||
deps: [_preflight, _pub-get]
|
deps: [_preflight, _pub-get]
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
generates:
|
|
||||||
- lib/**/*.g.dart
|
|
||||||
cmds:
|
|
||||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
analyze:
|
|
||||||
desc: Static analysis (flutter analyze)
|
|
||||||
deps: [_preflight, _codegen]
|
|
||||||
sources:
|
sources:
|
||||||
- lib/**/*.dart
|
- lib/**/*.dart
|
||||||
- test/**/*.dart
|
- test/**/*.dart
|
||||||
- pubspec.yaml
|
- pubspec.yaml
|
||||||
- analysis_options.yaml
|
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_analyze.sh
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
format:
|
|
||||||
desc: Format all Dart source files
|
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- "**/*.dart"
|
|
||||||
cmds:
|
|
||||||
- fvm dart format lib test
|
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues with dart fix --apply
|
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart fix --apply
|
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||||
@@ -161,23 +165,184 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm flutter test
|
- fvm flutter test
|
||||||
|
|
||||||
integration:
|
test-backend:
|
||||||
desc: Integration tests against a local Stalwart mail server
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
deps: [_flutter-check]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/integration/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/test.sh
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- integration_test/app_e2e_test.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/integration_ui_test.sh
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
|
sync-reliability:
|
||||||
|
desc: Run sync reliability runner (via Dagger)
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
|
test-android-firebase:
|
||||||
|
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY"
|
||||||
|
msg: "FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY is not set"
|
||||||
|
- sh: test -n "$FIREBASE_PROJECT_ID"
|
||||||
|
msg: "FIREBASE_PROJECT_ID is not set"
|
||||||
|
cmds:
|
||||||
|
- scripts/run_firebase_test.sh
|
||||||
|
|
||||||
|
ci-graph:
|
||||||
|
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
|
||||||
|
stalwart:
|
||||||
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
|
cmds:
|
||||||
|
- echo "Starting Stalwart on default ports (JMAP=8080, IMAP=1430, SMTP=1025, SIEVE=4190)"
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. stalwart up --ports 8080:8080 --ports 1430:1430 --ports 1025:1025 --ports 4190:4190
|
||||||
|
|
||||||
|
deploy-linux:
|
||||||
|
desc: Build and deploy Linux release via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
|
build-android-bundle:
|
||||||
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
|
cmds:
|
||||||
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
|
upload-android-bundle:
|
||||||
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
|
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||||
|
msg: "AAB not found — run build-android-bundle first"
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||||
|
|
||||||
|
publish-android:
|
||||||
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
|
deps: [generate-changelog]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||||
|
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
|
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
|
cmds:
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||||
|
|
||||||
|
deploy-apk:
|
||||||
|
desc: Build and deploy Android APK via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
|
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||||
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
|
cmds:
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
|
publish-website:
|
||||||
|
desc: Build and publish website via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||||
|
msg: "SSH_PRIVATE_KEY is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
|
check-dagger:
|
||||||
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
DAGGER_OUT=$(mktemp)
|
||||||
|
RC_FILE=$(mktemp)
|
||||||
|
_ts() { date -u '+[%H:%M:%S]'; }
|
||||||
|
run_dagger() {
|
||||||
|
: > "$DAGGER_OUT"; : > "$RC_FILE"
|
||||||
|
{ timeout --kill-after=10 600 "$@"; echo $? > "$RC_FILE"; } 2>&1 | tee "$DAGGER_OUT"
|
||||||
|
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||||
|
if [ "$RC" -eq 124 ] && grep -q "All tests passed" "$DAGGER_OUT"; then
|
||||||
|
echo "$(_ts) dagger: hung in teardown after success; treating as exit 0." >&2
|
||||||
|
RC=0
|
||||||
|
fi
|
||||||
|
return "$RC"
|
||||||
|
}
|
||||||
|
retry_dagger() {
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
run_dagger "$@" && return 0
|
||||||
|
RC=$?
|
||||||
|
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
||||||
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
|
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||||
|
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||||
|
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||||
|
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||||
|
sleep 90
|
||||||
|
else
|
||||||
|
return "$RC"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
rm -f "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
exit $RC
|
||||||
|
fi
|
||||||
|
PORTFILE=$(mktemp)
|
||||||
|
python3 ci/otel-receiver.py --port-file="$PORTFILE" &
|
||||||
|
RECV_PID=$!
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
until [ -s "$PORTFILE" ]; do
|
||||||
|
sleep 0.05
|
||||||
|
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
||||||
|
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
||||||
|
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
exit $RC
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
PORT=$(cat "$PORTFILE")
|
||||||
|
retry_dagger env \
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" \
|
||||||
|
dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
curl -sf "http://127.0.0.1:$PORT/shutdown" >/dev/null 2>&1 || true
|
||||||
|
wait "$RECV_PID" 2>/dev/null || true
|
||||||
|
exit $RC
|
||||||
|
|
||||||
|
dagger-prune:
|
||||||
|
desc: Prune the Dagger engine cache (keeps named volumes unless total exceeds 75 GB, then targets 50 GB)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||||
|
|
||||||
|
renovate:
|
||||||
|
desc: Run Renovate bot against the repository via Dagger
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||||
|
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
@@ -216,7 +381,103 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/linux/x64/release/bundle/sharedinbox
|
- build/linux/x64/release/bundle/sharedinbox
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub
|
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
deploy-linux-to-server:
|
||||||
|
desc: Package and deploy the Linux release bundle to the server, update latest.json
|
||||||
|
deps: [build-linux-release]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
||||||
|
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||||
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||||
|
# Merge with any existing latest.json so we don't overwrite the windows key
|
||||||
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
|
WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true)
|
||||||
|
if [ -n "$WINDOWS_URL" ]; then
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
else
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
fi
|
||||||
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
|
deploy-bugreport:
|
||||||
|
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
ssh "root@$SSH_HOST" "systemctl restart bugreport"
|
||||||
|
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
|
||||||
|
|
||||||
|
build-windows-release:
|
||||||
|
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||||
|
deps: [_pub-get, generate-changelog]
|
||||||
|
method: timestamp
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- windows/**/*
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- build/windows/x64/runner/Release/sharedinbox.exe
|
||||||
|
cmds:
|
||||||
|
- fvm flutter build windows --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
deploy-windows-to-server:
|
||||||
|
desc: Package and deploy the Windows release bundle to the server, update latest.json
|
||||||
|
deps: [build-windows-release]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||||
|
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||||
|
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||||
|
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||||
|
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
||||||
|
if [ -n "$LINUX_URL" ]; then
|
||||||
|
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
else
|
||||||
|
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||||
|
fi
|
||||||
|
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||||
|
|
||||||
|
|
||||||
_android-avd-setup:
|
_android-avd-setup:
|
||||||
@@ -266,20 +527,12 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/flutter-apk/app-release.apk
|
- build/app/outputs/flutter-apk/app-release.apk
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android-bundle:
|
build-android-bundle-local:
|
||||||
desc: Build release AAB and upload to Play Store internal track
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [build-android-bundle]
|
|
||||||
preconditions:
|
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
|
||||||
cmds:
|
|
||||||
- python3 scripts/deploy_playstore.py
|
|
||||||
|
|
||||||
build-android-bundle:
|
|
||||||
desc: Build a release App Bundle (AAB)
|
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
|
dotenv: [".env"]
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
- lib/**/*.dart
|
- lib/**/*.dart
|
||||||
@@ -288,7 +541,14 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||||
|
|
||||||
|
deploy-android-bundle:
|
||||||
|
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (local/fvm)
|
||||||
|
deps: [build-android-bundle-local]
|
||||||
|
dotenv: [".env"]
|
||||||
|
cmds:
|
||||||
|
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||||
|
|
||||||
deploy-android:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
@@ -315,7 +575,7 @@ tasks:
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
desc: Run the app on Linux desktop
|
desc: Run the app on Linux desktop
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter run -d linux --no-pub
|
- fvm flutter run -d linux --no-pub
|
||||||
|
|
||||||
@@ -356,19 +616,82 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/website-verify.sh
|
- scripts/website-verify.sh
|
||||||
|
|
||||||
|
deploy-apk-to-server:
|
||||||
|
desc: SCP the release APK to the server at public_html/builds/YYYY/MM/DD/
|
||||||
|
deps: [build-android]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
|
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||||
|
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||||
|
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||||
|
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||||
|
scp \
|
||||||
|
build/app/outputs/flutter-apk/app-release.apk \
|
||||||
|
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||||
|
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||||
|
|
||||||
|
generate-build-history:
|
||||||
|
desc: Generate Hugo build-history pages from Linux and Android builds on the server
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- python3 scripts/generate_build_history.py
|
||||||
|
|
||||||
|
website-publish:
|
||||||
|
desc: Generate build history, build Hugo site, and rsync to server (requires SSH_USER + SSH_HOST)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
cmds:
|
||||||
|
- task: generate-build-history
|
||||||
|
- task: website-deploy
|
||||||
|
|
||||||
website-deploy:
|
website-deploy:
|
||||||
desc: Deploy the website via rsync to public_html
|
desc: Deploy the website via rsync to public_html
|
||||||
deps: [website-build]
|
deps: [website-build]
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
-e "ssh -o StrictHostKeyChecking=no" \
|
--exclude='*.apk' \
|
||||||
|
--exclude='*.tar.gz' \
|
||||||
website/public/ \
|
website/public/ \
|
||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
||||||
deps: [analyze, check-coverage, check-hygiene]
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. check-fast
|
||||||
|
|
||||||
|
check-layers:
|
||||||
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
|
||||||
|
if [ -n "$VIOLATIONS" ]; then
|
||||||
|
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
|
||||||
|
echo "$VIOLATIONS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
check-hygiene:
|
check-hygiene:
|
||||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||||
@@ -384,11 +707,21 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
echo "Hygiene check passed."
|
echo "Hygiene check passed."
|
||||||
|
|
||||||
|
check-ci-images:
|
||||||
|
desc: Verify that all container images referenced in ci/main.go are reachable
|
||||||
|
cmds:
|
||||||
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
|
check-dagger-versions:
|
||||||
|
desc: Verify ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md pin the same Dagger version
|
||||||
|
cmds:
|
||||||
|
- scripts/check_dagger_versions.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
cmds:
|
cmds:
|
||||||
- task: integration
|
- task: test-backend
|
||||||
- task: integration-ui
|
- task: integration-ui
|
||||||
|
|
||||||
ci-logs:
|
ci-logs:
|
||||||
@@ -396,6 +729,17 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
||||||
|
|
||||||
|
screenshots:
|
||||||
|
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
|
||||||
|
deps: [_preflight, _codegen]
|
||||||
|
cmds:
|
||||||
|
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
|
||||||
|
chaos-monkey-backend:
|
||||||
|
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
|
|||||||
@@ -16,19 +16,23 @@ android {
|
|||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlin {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
create("release") {
|
|
||||||
// Hardcoded alias matching t.sh
|
if (ksPath != null) {
|
||||||
keyAlias = "upload"
|
signingConfigs {
|
||||||
// Use the same password for both key and keystore
|
create("release") {
|
||||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
keyAlias = "upload"
|
||||||
storePassword = pass
|
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
||||||
keyPassword = pass
|
storePassword = pass
|
||||||
storeFile = file("upload-keystore.jks")
|
keyPassword = pass
|
||||||
|
storeFile = file(ksPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +48,9 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Use the signing config defined above for release builds.
|
if (ksPath != null) {
|
||||||
// If the keystore file exists (e.g. in CI or manually placed), sign it.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
|
|
||||||
signingConfigs.getByName("release")
|
|
||||||
} else {
|
|
||||||
signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
ndk {
|
ndk {
|
||||||
@@ -67,7 +66,7 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
||||||
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
||||||
// references its class in all variants. Make it available for release compilation
|
// references its class in all variants. Make it available for release compilation
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package io.flutter.plugins;
|
||||||
|
|
||||||
|
import androidx.annotation.Keep;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import io.flutter.Log;
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generated file. Do not edit.
|
||||||
|
* This file is generated by the Flutter tool based on the
|
||||||
|
* plugins that support the Android platform.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public final class GeneratedPluginRegistrant {
|
||||||
|
private static final String TAG = "GeneratedPluginRegistrant";
|
||||||
|
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,2 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
org.gradle.welcome=never
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/dagger.gen.go linguist-generated
|
||||||
|
/internal/dagger/** linguist-generated
|
||||||
|
/internal/querybuilder/** linguist-generated
|
||||||
|
/internal/telemetry/** linguist-generated
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/dagger.gen.go
|
||||||
|
/internal/dagger
|
||||||
|
/internal/querybuilder
|
||||||
|
/internal/telemetry
|
||||||
|
/.env
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "ci",
|
||||||
|
"engineVersion": "v0.20.8",
|
||||||
|
"sdk": {
|
||||||
|
"source": "go"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Minimal OTLP HTTP/protobuf trace receiver for Dagger CI timing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 ci/otel-receiver.py --port-file=/tmp/otel.port
|
||||||
|
|
||||||
|
Caller sets:
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
# ── Minimal protobuf binary decoder ─────────────────────────────────────────
|
||||||
|
# Only decodes the fields we need; skips everything else safely.
|
||||||
|
|
||||||
|
def _varint(buf, pos):
|
||||||
|
n, shift = 0, 0
|
||||||
|
while pos < len(buf):
|
||||||
|
b = buf[pos]; pos += 1
|
||||||
|
n |= (b & 0x7F) << shift
|
||||||
|
shift += 7
|
||||||
|
if not (b & 0x80):
|
||||||
|
return n, pos
|
||||||
|
raise ValueError("truncated varint")
|
||||||
|
|
||||||
|
|
||||||
|
def _fields(buf):
|
||||||
|
"""Yield (field_num, wire_type, raw_value) for each field in a message."""
|
||||||
|
pos = 0
|
||||||
|
while pos < len(buf):
|
||||||
|
tag, pos = _varint(buf, pos)
|
||||||
|
wt, fn = tag & 7, tag >> 3
|
||||||
|
if wt == 0: # varint
|
||||||
|
v, pos = _varint(buf, pos)
|
||||||
|
elif wt == 1: # fixed64
|
||||||
|
v = struct.unpack_from("<Q", buf, pos)[0]; pos += 8
|
||||||
|
elif wt == 2: # length-delimited
|
||||||
|
n, pos = _varint(buf, pos)
|
||||||
|
v = buf[pos:pos + n]; pos += n
|
||||||
|
elif wt == 5: # fixed32
|
||||||
|
v = struct.unpack_from("<I", buf, pos)[0]; pos += 4
|
||||||
|
else:
|
||||||
|
break # unknown: stop
|
||||||
|
yield fn, wt, v
|
||||||
|
|
||||||
|
|
||||||
|
def _any_value(buf):
|
||||||
|
"""Parse AnyValue, return (type_tag, python_value)."""
|
||||||
|
for fn, wt, v in _fields(buf):
|
||||||
|
if fn == 1 and wt == 2: # string_value
|
||||||
|
return "str", v.decode("utf-8", errors="replace")
|
||||||
|
if fn == 2 and wt == 0: # bool_value
|
||||||
|
return "bool", bool(v)
|
||||||
|
if fn == 3 and wt == 0: # int_value (sint64)
|
||||||
|
return "int", v
|
||||||
|
if fn == 4 and wt == 1: # double_value
|
||||||
|
return "float", struct.unpack("<d", struct.pack("<Q", v))[0]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _keyvalue(buf):
|
||||||
|
key, tag, val = None, None, None
|
||||||
|
for fn, wt, v in _fields(buf):
|
||||||
|
if fn == 1 and wt == 2:
|
||||||
|
key = v.decode("utf-8", errors="replace")
|
||||||
|
elif fn == 2 and wt == 2:
|
||||||
|
tag, val = _any_value(v)
|
||||||
|
return key, tag, val
|
||||||
|
|
||||||
|
|
||||||
|
def _span(buf):
|
||||||
|
name = ""
|
||||||
|
start_ns = end_ns = 0
|
||||||
|
cached = False
|
||||||
|
for fn, wt, v in _fields(buf):
|
||||||
|
if fn == 5 and wt == 2: # name
|
||||||
|
name = v.decode("utf-8", errors="replace")
|
||||||
|
elif fn == 7 and wt == 1: # start_time_unix_nano
|
||||||
|
start_ns = v
|
||||||
|
elif fn == 8 and wt == 1: # end_time_unix_nano
|
||||||
|
end_ns = v
|
||||||
|
elif fn == 9 and wt == 2: # attributes (repeated)
|
||||||
|
k, tag, val = _keyvalue(v)
|
||||||
|
if tag == "bool" and k and "cached" in k.lower():
|
||||||
|
cached = val
|
||||||
|
return {"name": name, "dur": max(0.0, (end_ns - start_ns) / 1e9), "cached": cached}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(body):
|
||||||
|
spans = []
|
||||||
|
for fn1, wt1, rs in _fields(body): # resource_spans = 1
|
||||||
|
if fn1 != 1 or wt1 != 2:
|
||||||
|
continue
|
||||||
|
for fn2, wt2, ss in _fields(rs): # scope_spans = 2
|
||||||
|
if fn2 != 2 or wt2 != 2:
|
||||||
|
continue
|
||||||
|
for fn3, wt3, sp in _fields(ss): # spans = 2
|
||||||
|
if fn3 == 2 and wt3 == 2:
|
||||||
|
spans.append(_span(sp))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP receiver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_spans = []
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def _respond(self, code, body=b""):
|
||||||
|
self.close_connection = True # actually close after response, matching the header
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/x-protobuf")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
if body:
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path != "/shutdown":
|
||||||
|
self._respond(404); return
|
||||||
|
self._respond(200, b"shutting down")
|
||||||
|
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/v1/traces":
|
||||||
|
self._respond(404); return
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(n)
|
||||||
|
try:
|
||||||
|
decoded = _decode(body)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[otel-receiver] decode error: {exc}", file=sys.stderr, flush=True)
|
||||||
|
self._respond(400, str(exc).encode()); return
|
||||||
|
with _lock:
|
||||||
|
_spans.extend(decoded)
|
||||||
|
self._respond(200)
|
||||||
|
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timing report ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _report():
|
||||||
|
with _lock:
|
||||||
|
if not _spans:
|
||||||
|
print("otel-receiver: no spans received", file=sys.stderr)
|
||||||
|
return
|
||||||
|
rows = sorted(_spans, key=lambda r: r["dur"], reverse=True)
|
||||||
|
NAME_W = 38
|
||||||
|
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
|
||||||
|
print("─" * (6 + 2 + 8 + 2 + NAME_W + 20))
|
||||||
|
for r in rows:
|
||||||
|
status = "CACHED" if r["cached"] else "LIVE"
|
||||||
|
name = r["name"]
|
||||||
|
if len(name) > NAME_W:
|
||||||
|
name = name[: NAME_W - 1] + "…"
|
||||||
|
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
|
||||||
|
print(f"\n{len(rows)} spans total")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port-file", default="")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||||
|
if args.port_file:
|
||||||
|
with open(args.port_file, "w") as f:
|
||||||
|
f.write(str(server.server_address[1]))
|
||||||
|
|
||||||
|
def _shutdown(sig, frame):
|
||||||
|
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
|
||||||
|
server.serve_forever()
|
||||||
|
_report()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,3 +3,48 @@
|
|||||||
Installed like explained here:
|
Installed like explained here:
|
||||||
|
|
||||||
https://forgejo.org/docs/next/admin/actions/installation/binary/
|
https://forgejo.org/docs/next/admin/actions/installation/binary/
|
||||||
|
|
||||||
|
## Connecting to Dagger (via stunnel)
|
||||||
|
|
||||||
|
Dagger is running on the host machine and exported via stunnel on port 8774. The runner connects to it using a local stunnel client.
|
||||||
|
|
||||||
|
The following TLS secrets must be configured as environment variables in Codeberg:
|
||||||
|
- `DAGGER_CLIENT_CERT`: Content of `client.crt`
|
||||||
|
- `DAGGER_CLIENT_KEY`: Content of `client.key`
|
||||||
|
- `DAGGER_CA_CERT`: Content of `ca.crt`
|
||||||
|
|
||||||
|
### Setup Script
|
||||||
|
|
||||||
|
This snippet can be used in a CI job to establish the connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write TLS files from environment variables
|
||||||
|
mkdir -p /etc/dagger/tls
|
||||||
|
echo "$DAGGER_CLIENT_CERT" > /etc/dagger/tls/client.crt
|
||||||
|
echo "$DAGGER_CLIENT_KEY" > /etc/dagger/tls/client.key
|
||||||
|
echo "$DAGGER_CA_CERT" > /etc/dagger/tls/ca.crt
|
||||||
|
|
||||||
|
# Create stunnel configuration
|
||||||
|
cat > /tmp/dagger-client.conf << EOF
|
||||||
|
foreground = yes
|
||||||
|
pid =
|
||||||
|
|
||||||
|
[dagger]
|
||||||
|
client = yes
|
||||||
|
accept = 127.0.0.1:1774
|
||||||
|
connect = <server-ip>:8774
|
||||||
|
cert = /etc/dagger/tls/client.crt
|
||||||
|
key = /etc/dagger/tls/client.key
|
||||||
|
CAfile = /etc/dagger/tls/ca.crt
|
||||||
|
verify = 2
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start stunnel in the background
|
||||||
|
stunnel /tmp/dagger-client.conf &
|
||||||
|
|
||||||
|
# Configure Dagger to use the tunnel
|
||||||
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||||
|
dagger version
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Replace `<server-ip>` with the actual IP address of the machine running Dagger.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Load .env into environment
|
||||||
|
set -a
|
||||||
|
# shellcheck source=.env
|
||||||
|
source "$REPO_DIR/.env"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
# SSH_PRIVATE_KEY must not live in .env (dagger parses .env and chokes on multiline values)
|
||||||
|
export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
||||||
|
|
||||||
|
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||||
|
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||||
|
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
||||||
|
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||||
|
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||||
|
done
|
||||||
|
|
||||||
|
exec python3 "$REPO_DIR/deploy_cron.py"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cron deploy script for sharedinbox website.
|
||||||
|
Runs every 5 minutes; skips if origin/main has not changed since last trigger.
|
||||||
|
Triggers the 'Deploy Website' Forgejo Actions workflow via fgj on each new commit.
|
||||||
|
Forgejo Actions handles failure reporting.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_DIR = Path(__file__).parent.resolve()
|
||||||
|
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||||
|
REPO = 'guettli/sharedinbox'
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
['git', *args], cwd=REPO_DIR, check=True,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: Path) -> str:
|
||||||
|
return path.read_text().strip() if path.exists() else ''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
git('fetch', 'origin', 'main')
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print(f'git fetch failed (transient?): {exc} — skipping this run.', file=sys.stderr)
|
||||||
|
return
|
||||||
|
remote_sha = git('rev-parse', 'origin/main')
|
||||||
|
last_sha = read(SHA_FILE)
|
||||||
|
|
||||||
|
if remote_sha == last_sha:
|
||||||
|
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f'New commit {remote_sha[:8]} (was {last_sha[:8] or "none"}) — triggering workflow...')
|
||||||
|
result = subprocess.run(
|
||||||
|
['fgj', 'actions', 'workflow', 'run', 'website.yml', '-R', REPO],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f'fgj workflow run failed: {result.stderr}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
SHA_FILE.write_text(remote_sha + '\n')
|
||||||
|
print('Workflow triggered.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -4,6 +4,28 @@ 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)
|
||||||
|
|
||||||
|
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||||
|
dependencies up to date. All required components are in main:
|
||||||
|
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
||||||
|
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
||||||
|
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
||||||
|
- `Taskfile.yml` — `renovate` task
|
||||||
|
- Issue #257 closed.
|
||||||
|
|
||||||
## Tasks (2026-05-11)
|
## Tasks (2026-05-11)
|
||||||
|
|
||||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778430510,
|
|
||||||
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.11",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
{
|
|
||||||
description = "SharedInbox — IMAP/SMTP Flutter client";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
pkgs = import nixpkgs { inherit system; };
|
|
||||||
|
|
||||||
# All Linux desktop runtime libraries needed by flutter build linux and
|
|
||||||
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
|
||||||
# it for both buildInputs and LD_LIBRARY_PATH / PKG_CONFIG_PATH.
|
|
||||||
linuxDesktopLibs = with pkgs; [
|
|
||||||
gtk3
|
|
||||||
libsecret
|
|
||||||
fontconfig
|
|
||||||
libepoxy
|
|
||||||
mesa
|
|
||||||
libGL # libglvnd — vendor-neutral GL/EGL/GLX dispatch layer
|
|
||||||
at-spi2-core
|
|
||||||
glib
|
|
||||||
pango
|
|
||||||
cairo
|
|
||||||
gdk-pixbuf
|
|
||||||
harfbuzz
|
|
||||||
];
|
|
||||||
fgj = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "fgj";
|
|
||||||
version = "0.4.0";
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
url = "https://codeberg.org/romaintb/fgj/releases/download/v0.4.0/fgj_linux_amd64";
|
|
||||||
sha256 = "07pia03facvvxq9i1dgl7p47ccv1iqj4drpkp45gvw26d4afkbj7";
|
|
||||||
};
|
|
||||||
dontUnpack = true;
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp $src $out/bin/fgj
|
|
||||||
chmod +x $out/bin/fgj
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
# Java JDK — required by Gradle for Android builds
|
|
||||||
jdk17
|
|
||||||
|
|
||||||
# Task runner
|
|
||||||
go-task
|
|
||||||
|
|
||||||
# Flutter version manager — needed for host builds (task build-linux, task run)
|
|
||||||
fvm
|
|
||||||
|
|
||||||
# Git hooks
|
|
||||||
pre-commit
|
|
||||||
|
|
||||||
# Linux desktop build + runtime dependencies (flutter build linux / task run)
|
|
||||||
] ++ linuxDesktopLibs ++ (with pkgs; [
|
|
||||||
pkg-config
|
|
||||||
clang
|
|
||||||
cmake
|
|
||||||
ninja
|
|
||||||
|
|
||||||
# Local IMAP/SMTP dev server for integration tests
|
|
||||||
stalwart-mail
|
|
||||||
|
|
||||||
# Headless display for UI integration tests
|
|
||||||
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
|
|
||||||
|
|
||||||
# Coverage merging (flutter test --merge-coverage requires lcov)
|
|
||||||
lcov
|
|
||||||
|
|
||||||
# Website
|
|
||||||
hugo
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
git
|
|
||||||
curl
|
|
||||||
jq
|
|
||||||
sqlite
|
|
||||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
|
||||||
(python3.withPackages (ps: with ps; [
|
|
||||||
google-api-python-client
|
|
||||||
google-auth-httplib2
|
|
||||||
httplib2
|
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
|
||||||
]);
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
|
||||||
export IN_NIX_SHELL=1
|
|
||||||
|
|
||||||
# Disable Flutter telemetry inside dev shell
|
|
||||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
|
||||||
|
|
||||||
# Expose dev headers to cmake's FindPkgConfig.
|
|
||||||
# The nix pkg-config wrapper works in bash but cmake invokes pkg-config
|
|
||||||
# as a subprocess and needs PKG_CONFIG_PATH set explicitly.
|
|
||||||
export PKG_CONFIG_PATH="${pkgs.gtk3.dev}/lib/pkgconfig:${pkgs.glib.dev}/lib/pkgconfig:${pkgs.pango.dev}/lib/pkgconfig:${pkgs.cairo.dev}/lib/pkgconfig:${pkgs.gdk-pixbuf.dev}/lib/pkgconfig:${pkgs.at-spi2-core.dev}/lib/pkgconfig:${pkgs.harfbuzz.dev}/lib/pkgconfig:${pkgs.libsecret}/lib/pkgconfig:${pkgs.fontconfig.dev}/lib/pkgconfig:${pkgs.libepoxy}/lib/pkgconfig:$PKG_CONFIG_PATH"
|
|
||||||
|
|
||||||
# Nix ld uses --no-copy-dt-needed-entries (strict mode): transitive shared-lib
|
|
||||||
# deps are not followed automatically, so link them explicitly.
|
|
||||||
export LDFLAGS="-L${pkgs.fontconfig.lib}/lib -lfontconfig $LDFLAGS"
|
|
||||||
|
|
||||||
# Make nix-built runtime libs visible to the dynamic linker so the
|
|
||||||
# Flutter Linux bundle and integration-ui tests can run.
|
|
||||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath linuxDesktopLibs}:$LD_LIBRARY_PATH"
|
|
||||||
|
|
||||||
# Wire the libglvnd dispatch to the nix mesa vendor ICDs so GTK/Flutter
|
|
||||||
# can create an OpenGL (EGL + GLX) context under Xvfb without a real GPU.
|
|
||||||
export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d"
|
|
||||||
export __GLX_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/lib"
|
|
||||||
export LIBGL_ALWAYS_SOFTWARE=1
|
|
||||||
export MESA_LOADER_DRIVER_OVERRIDE=softpipe
|
|
||||||
|
|
||||||
echo "SharedInbox Flutter dev environment ready."
|
|
||||||
echo " Analyze : task analyze"
|
|
||||||
echo " Unit tests : task test"
|
|
||||||
echo " Integration : task integration"
|
|
||||||
echo " All checks : task check"
|
|
||||||
echo " Run (Linux) : task run"
|
|
||||||
echo " Start Stalwart : stalwart-dev/start"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
|
||||||
|
<!-- White Background -->
|
||||||
|
<rect width="512" height="512" fill="white"/>
|
||||||
|
|
||||||
|
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
|
||||||
|
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
|
||||||
|
<!-- Red -->
|
||||||
|
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
|
||||||
|
|
||||||
|
<!-- Orange -->
|
||||||
|
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
|
||||||
|
|
||||||
|
<!-- Yellow -->
|
||||||
|
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
|
||||||
|
|
||||||
|
<!-- Green -->
|
||||||
|
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
|
||||||
|
|
||||||
|
<!-- Blue -->
|
||||||
|
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
|
||||||
|
|
||||||
|
<!-- Purple -->
|
||||||
|
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -112,12 +112,28 @@ void main() {
|
|||||||
late String userPass;
|
late String userPass;
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
const required = [
|
||||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
|
'STALWART_IMAP_HOST',
|
||||||
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
'STALWART_IMAP_PORT',
|
||||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
|
'STALWART_SMTP_HOST',
|
||||||
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
'STALWART_SMTP_PORT',
|
||||||
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
'STALWART_USER_B',
|
||||||
|
'STALWART_PASS_B',
|
||||||
|
];
|
||||||
|
final missing = required.where((k) => Platform.environment[k] == null).toList();
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
fail(
|
||||||
|
'Missing required environment variables: ${missing.join(', ')}. '
|
||||||
|
'This test requires a running Stalwart instance — '
|
||||||
|
'run via stalwart-dev/integration_ui_test.sh.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
imapHost = Platform.environment['STALWART_IMAP_HOST']!;
|
||||||
|
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT']!);
|
||||||
|
smtpHost = Platform.environment['STALWART_SMTP_HOST']!;
|
||||||
|
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT']!);
|
||||||
|
userEmail = Platform.environment['STALWART_USER_B']!;
|
||||||
|
userPass = Platform.environment['STALWART_PASS_B']!;
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -130,17 +146,12 @@ void main() {
|
|||||||
addTearDown(tester.view.resetPhysicalSize);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
addTearDown(tester.view.resetDevicePixelRatio);
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
// On Android, the keyboard-dismiss / window-resize cycle can trigger
|
// Capture the test binding's error recorder and error-widget builder
|
||||||
// one final layout pass on already-disposed render objects (DEFUNCT).
|
// BEFORE app.main() so teardown can restore both. app.main() overwrites
|
||||||
// These spurious overflow errors have no effect on real functionality;
|
// FlutterError.onError (crash-screen handler) and ErrorWidget.builder;
|
||||||
// filter them so they don't fail the test.
|
// the test binding verifies both are unchanged after the test completes.
|
||||||
final prevError = FlutterError.onError;
|
final bindingError = FlutterError.onError;
|
||||||
FlutterError.onError = (details) {
|
final bindingErrorWidgetBuilder = ErrorWidget.builder;
|
||||||
final msg = details.toString();
|
|
||||||
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
|
||||||
prevError?.call(details);
|
|
||||||
};
|
|
||||||
addTearDown(() => FlutterError.onError = prevError);
|
|
||||||
|
|
||||||
_log('app start');
|
_log('app start');
|
||||||
app.main(
|
app.main(
|
||||||
@@ -155,7 +166,36 @@ void main() {
|
|||||||
accountConnectionStatusProvider.overrideWith((ref, _) async {}),
|
accountConnectionStatusProvider.overrideWith((ref, _) async {}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await pumpUntil(tester, find.text('No accounts yet.'));
|
|
||||||
|
// app.main() sets both FlutterError.onError (crash handler) and
|
||||||
|
// ErrorWidget.builder (CrashScreen builder). The binding captures
|
||||||
|
// ErrorWidget.builder BEFORE testBody() and verifies it is unchanged
|
||||||
|
// AFTER testBody() returns — addTearDown fires too late for that check.
|
||||||
|
// Restore ErrorWidget.builder here, immediately after app.main().
|
||||||
|
ErrorWidget.builder = bindingErrorWidgetBuilder;
|
||||||
|
|
||||||
|
// Override the crash handler with a filter that forwards non-spurious
|
||||||
|
// errors to the binding's recorder. addTearDown is fine for
|
||||||
|
// FlutterError.onError because the binding checks it via _recordError
|
||||||
|
// which is called on the next error, not in a post-body verify pass.
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
final msg = details.toString();
|
||||||
|
// DEFUNCT/DISPOSED: keyboard-dismiss or teardown layout errors on
|
||||||
|
// Android/Linux that have no effect on real functionality.
|
||||||
|
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
||||||
|
// _zOrderIndex: Flutter 3.41.6 bug — _RawAutocompleteState.dispose()
|
||||||
|
// removes _updateOptionsViewVisibility from the external FocusNode but
|
||||||
|
// forgets to remove _onFocusChange. When the state is rebuilt with the
|
||||||
|
// same FocusNode both listeners accumulate and the second hide() call
|
||||||
|
// hits the _zOrderIndex != null assertion in overlay.dart:1681.
|
||||||
|
// Tracked upstream: https://github.com/flutter/flutter/issues
|
||||||
|
// This filter must be removed once we upgrade past the fix.
|
||||||
|
if (msg.contains('_zOrderIndex')) return;
|
||||||
|
bindingError?.call(details);
|
||||||
|
};
|
||||||
|
addTearDown(() => FlutterError.onError = bindingError);
|
||||||
|
|
||||||
|
await pumpUntil(tester, find.text('Welcome to sharedinbox.de'));
|
||||||
_log('app settled');
|
_log('app settled');
|
||||||
|
|
||||||
// ── Add account ────────────────────────────────────────────────────────
|
// ── Add account ────────────────────────────────────────────────────────
|
||||||
@@ -248,6 +288,12 @@ void main() {
|
|||||||
find.widgetWithText(TextFormField, 'To'),
|
find.widgetWithText(TextFormField, 'To'),
|
||||||
userEmail,
|
userEmail,
|
||||||
);
|
);
|
||||||
|
// Explicitly unfocus the To field so RawAutocomplete closes its overlay
|
||||||
|
// via a single FocusNode notification BEFORE Subject takes focus.
|
||||||
|
// A plain pump() is insufficient — the double hide() fires synchronously
|
||||||
|
// during the focus-dispatch triggered by the next enterText call.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.widgetWithText(TextFormField, 'Subject'),
|
find.widgetWithText(TextFormField, 'Subject'),
|
||||||
subject,
|
subject,
|
||||||
@@ -257,6 +303,10 @@ void main() {
|
|||||||
await tester.ensureVisible(bodyField);
|
await tester.ensureVisible(bodyField);
|
||||||
await tester.enterText(bodyField, 'Hello from integration test!');
|
await tester.enterText(bodyField, 'Hello from integration test!');
|
||||||
|
|
||||||
|
// Unfocus before sending so the autocomplete overlay closes cleanly
|
||||||
|
// before ComposeScreen is popped, avoiding a second hide() on unmount.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pump();
|
||||||
_log('send email');
|
_log('send email');
|
||||||
await tester.tap(find.byIcon(Icons.send));
|
await tester.tap(find.byIcon(Icons.send));
|
||||||
// Wait for ComposeScreen to pop back to EmailListScreen after send.
|
// Wait for ComposeScreen to pop back to EmailListScreen after send.
|
||||||
@@ -267,7 +317,7 @@ void main() {
|
|||||||
|
|
||||||
// ── Check Sent folder ──────────────────────────────────────────────────
|
// ── Check Sent folder ──────────────────────────────────────────────────
|
||||||
// Use the drawer to switch folders (no back button on Linux desktop).
|
// Use the drawer to switch folders (no back button on Linux desktop).
|
||||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
await tester.tap(find.byTooltip('Open folders'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Sent'));
|
await tester.tap(find.text('Sent'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -281,7 +331,7 @@ void main() {
|
|||||||
expect(find.text(subject), findsOneWidget);
|
expect(find.text(subject), findsOneWidget);
|
||||||
|
|
||||||
// ── Check Inbox ────────────────────────────────────────────────────────
|
// ── Check Inbox ────────────────────────────────────────────────────────
|
||||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
await tester.tap(find.byTooltip('Open folders'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('INBOX'));
|
await tester.tap(find.text('INBOX'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
const int dbSchemaVersion = 41;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
enum FilterField {
|
||||||
|
from_,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
size;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
FilterField.from_ => 'From',
|
||||||
|
FilterField.to => 'To',
|
||||||
|
FilterField.cc => 'CC',
|
||||||
|
FilterField.subject => 'Subject',
|
||||||
|
FilterField.size => 'Size (bytes)',
|
||||||
|
};
|
||||||
|
|
||||||
|
List<FilterComparison> get allowedComparisons => switch (this) {
|
||||||
|
FilterField.size => [FilterComparison.over, FilterComparison.under],
|
||||||
|
_ => [
|
||||||
|
FilterComparison.contains,
|
||||||
|
FilterComparison.is_,
|
||||||
|
FilterComparison.matches,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FilterComparison {
|
||||||
|
contains,
|
||||||
|
is_,
|
||||||
|
matches,
|
||||||
|
over,
|
||||||
|
under;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
FilterComparison.contains => 'contains',
|
||||||
|
FilterComparison.is_ => 'is',
|
||||||
|
FilterComparison.matches => 'matches',
|
||||||
|
FilterComparison.over => 'over',
|
||||||
|
FilterComparison.under => 'under',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FilterOperator { and_, or_ }
|
||||||
|
|
||||||
|
sealed class FilterNode {}
|
||||||
|
|
||||||
|
final class FilterLeaf extends FilterNode {
|
||||||
|
FilterLeaf({
|
||||||
|
required this.field,
|
||||||
|
required this.comparison,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterField field;
|
||||||
|
final FilterComparison comparison;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
FilterLeaf copyWith({
|
||||||
|
FilterField? field,
|
||||||
|
FilterComparison? comparison,
|
||||||
|
String? value,
|
||||||
|
}) =>
|
||||||
|
FilterLeaf(
|
||||||
|
field: field ?? this.field,
|
||||||
|
comparison: comparison ?? this.comparison,
|
||||||
|
value: value ?? this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FilterGroup extends FilterNode {
|
||||||
|
FilterGroup({required this.operator, required this.children});
|
||||||
|
|
||||||
|
final FilterOperator operator;
|
||||||
|
final List<FilterNode> children;
|
||||||
|
|
||||||
|
bool get isEmpty => children.isEmpty;
|
||||||
|
|
||||||
|
FilterGroup copyWith({
|
||||||
|
FilterOperator? operator,
|
||||||
|
List<FilterNode>? children,
|
||||||
|
}) =>
|
||||||
|
FilterGroup(
|
||||||
|
operator: operator ?? this.operator,
|
||||||
|
children: children ?? this.children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static FilterGroup empty() =>
|
||||||
|
FilterGroup(operator: FilterOperator.and_, children: []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
|
||||||
|
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
|
||||||
|
/// suitable for display in the visual filter editor.
|
||||||
|
///
|
||||||
|
/// Returns null if the script uses features outside the supported subset.
|
||||||
|
class FilterSieveConverter {
|
||||||
|
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
|
||||||
|
try {
|
||||||
|
final s = _Sc(script);
|
||||||
|
s.skip();
|
||||||
|
if (s.peekWord() == 'require') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
_parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
s.skip();
|
||||||
|
}
|
||||||
|
if (s.peekWord() != 'if') return null;
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final node = _parseTest(s);
|
||||||
|
if (node == null) return null;
|
||||||
|
s.skip();
|
||||||
|
s.expectChar('{');
|
||||||
|
s.skip();
|
||||||
|
final actions = <SieveAction>[];
|
||||||
|
while (s.peek() != '}' && !s.isAtEnd) {
|
||||||
|
final action = _parseAction(s);
|
||||||
|
if (action == null) return null;
|
||||||
|
actions.add(action);
|
||||||
|
s.skip();
|
||||||
|
}
|
||||||
|
s.expectChar('}');
|
||||||
|
final group = switch (node) {
|
||||||
|
final FilterGroup g => g,
|
||||||
|
final FilterLeaf l =>
|
||||||
|
FilterGroup(operator: FilterOperator.and_, children: [l]),
|
||||||
|
};
|
||||||
|
return (group: group, actions: actions);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterNode? _parseTest(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
if (word == 'allof' || word == 'anyof') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
s.expectChar('(');
|
||||||
|
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
|
||||||
|
final children = <FilterNode>[];
|
||||||
|
while (true) {
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ')') break;
|
||||||
|
final child = _parseTest(s);
|
||||||
|
if (child == null) return null;
|
||||||
|
children.add(child);
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ',') s.advance();
|
||||||
|
}
|
||||||
|
s.expectChar(')');
|
||||||
|
return FilterGroup(operator: op, children: children);
|
||||||
|
}
|
||||||
|
return _parseSingleTest(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterLeaf? _parseSingleTest(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
|
||||||
|
if (word == 'address') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final matchType = s.readTaggedArg();
|
||||||
|
s.skip();
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
final values = _parseStringOrList(s);
|
||||||
|
final field = switch (headers.firstOrNull?.toLowerCase()) {
|
||||||
|
'from' => FilterField.from_,
|
||||||
|
'to' => FilterField.to,
|
||||||
|
'cc' => FilterField.cc,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (field == null) return null;
|
||||||
|
final comp = _comp(matchType);
|
||||||
|
if (comp == null) return null;
|
||||||
|
return FilterLeaf(
|
||||||
|
field: field,
|
||||||
|
comparison: comp,
|
||||||
|
value: values.firstOrNull ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'header') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final matchType = s.readTaggedArg();
|
||||||
|
s.skip();
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
final values = _parseStringOrList(s);
|
||||||
|
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
|
||||||
|
final comp = _comp(matchType);
|
||||||
|
if (comp == null) return null;
|
||||||
|
return FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: comp,
|
||||||
|
value: values.firstOrNull ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'size') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final compTag = s.readTaggedArg();
|
||||||
|
s.skip();
|
||||||
|
final numStr = s.readDigits();
|
||||||
|
final comp = switch (compTag.toLowerCase()) {
|
||||||
|
':over' => FilterComparison.over,
|
||||||
|
':under' => FilterComparison.under,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (comp == null) return null;
|
||||||
|
return FilterLeaf(
|
||||||
|
field: FilterField.size,
|
||||||
|
comparison: comp,
|
||||||
|
value: numStr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
|
||||||
|
':contains' => FilterComparison.contains,
|
||||||
|
':is' => FilterComparison.is_,
|
||||||
|
':matches' => FilterComparison.matches,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
SieveAction? _parseAction(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
if (word == 'fileinto') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final folder = _parseString(s);
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
return FileIntoAction(folder);
|
||||||
|
}
|
||||||
|
if (word == 'keep') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
return KeepAction();
|
||||||
|
}
|
||||||
|
if (word == 'discard') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
return DiscardAction();
|
||||||
|
}
|
||||||
|
if (word == 'setflag' || word == 'addflag') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final flags = _parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
if (flags.any(
|
||||||
|
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||||
|
)) {
|
||||||
|
return MarkAsSeenAction();
|
||||||
|
}
|
||||||
|
return FlagAction(flags);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseStringOrList(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == '[') {
|
||||||
|
s.advance();
|
||||||
|
final items = <String>[];
|
||||||
|
while (true) {
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ']') {
|
||||||
|
s.advance();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
items.add(_parseString(s));
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ',') s.advance();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return [_parseString(s)];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseString(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
return s.readQuotedString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal scanner for the supported Sieve subset.
|
||||||
|
class _Sc {
|
||||||
|
_Sc(this._src);
|
||||||
|
final String _src;
|
||||||
|
int _pos = 0;
|
||||||
|
|
||||||
|
bool get isAtEnd => _pos >= _src.length;
|
||||||
|
String? peek() => isAtEnd ? null : _src[_pos];
|
||||||
|
|
||||||
|
String advance() {
|
||||||
|
if (isAtEnd) throw _ScanErr('Unexpected end');
|
||||||
|
return _src[_pos++];
|
||||||
|
}
|
||||||
|
|
||||||
|
void skip() {
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||||
|
_pos++;
|
||||||
|
} else if (ch == '#') {
|
||||||
|
while (!isAtEnd && _src[_pos] != '\n') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||||
|
_pos += 2;
|
||||||
|
while (_pos + 1 < _src.length) {
|
||||||
|
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||||
|
_pos += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? peekWord() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) return ch;
|
||||||
|
if (ch == ':') {
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (end < _src.length && _wc(_src[end])) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(_pos, end).toLowerCase();
|
||||||
|
}
|
||||||
|
if (_wc(ch)) {
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (end < _src.length && _wc(_src[end])) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(_pos, end).toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readWord() {
|
||||||
|
final start = _pos;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) {
|
||||||
|
_pos++;
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
if (ch == ':') {
|
||||||
|
_pos++;
|
||||||
|
while (!isAtEnd && _wc(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (!isAtEnd && _wc(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _src.substring(start, _pos).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String readTaggedArg() {
|
||||||
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
|
throw _ScanErr('Expected tagged arg at $_pos');
|
||||||
|
}
|
||||||
|
|
||||||
|
String readDigits() {
|
||||||
|
final start = _pos;
|
||||||
|
while (!isAtEnd && _dig(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
|
||||||
|
return _src.substring(start, _pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
String readQuotedString() {
|
||||||
|
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
|
||||||
|
_pos++;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == '"') {
|
||||||
|
_pos++;
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||||
|
_pos++;
|
||||||
|
buf.write(_src[_pos]);
|
||||||
|
_pos++;
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw _ScanErr('Unterminated string');
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectChar(String ch) {
|
||||||
|
skip();
|
||||||
|
if (isAtEnd || _src[_pos] != ch) {
|
||||||
|
throw _ScanErr(
|
||||||
|
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _wc(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return (c >= 0x41 && c <= 0x5A) ||
|
||||||
|
(c >= 0x61 && c <= 0x7A) ||
|
||||||
|
(c >= 0x30 && c <= 0x39) ||
|
||||||
|
c == 0x5F ||
|
||||||
|
c == 0x2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _dig(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return c >= 0x30 && c <= 0x39;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanErr implements Exception {
|
||||||
|
_ScanErr(this.message);
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
@@ -192,6 +192,22 @@ class EmailThread {
|
|||||||
required this.accountId,
|
required this.accountId,
|
||||||
required this.mailboxPath,
|
required this.mailboxPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Wraps a single [Email] as a one-message thread for uniform rendering.
|
||||||
|
factory EmailThread.fromEmail(Email e) => EmailThread(
|
||||||
|
threadId: e.threadId ?? e.id,
|
||||||
|
subject: e.subject,
|
||||||
|
participants: e.from,
|
||||||
|
latestDate: e.sentAt ?? e.receivedAt,
|
||||||
|
messageCount: 1,
|
||||||
|
hasUnread: !e.isSeen,
|
||||||
|
isFlagged: e.isFlagged,
|
||||||
|
latestEmailId: e.id,
|
||||||
|
preview: e.preview,
|
||||||
|
emailIds: [e.id],
|
||||||
|
accountId: e.accountId,
|
||||||
|
mailboxPath: e.mailboxPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EmailAddress {
|
class EmailAddress {
|
||||||
@@ -232,12 +248,29 @@ class EmailHeader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Full message body — fetched on demand, cached in the local DB.
|
/// Full message body — fetched on demand, cached in the local DB.
|
||||||
|
class MimePart {
|
||||||
|
final String contentType;
|
||||||
|
final String? filename;
|
||||||
|
final int? size;
|
||||||
|
final String? encoding;
|
||||||
|
final List<MimePart> children;
|
||||||
|
|
||||||
|
const MimePart({
|
||||||
|
required this.contentType,
|
||||||
|
this.filename,
|
||||||
|
this.size,
|
||||||
|
this.encoding,
|
||||||
|
this.children = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class EmailBody {
|
class EmailBody {
|
||||||
final String emailId;
|
final String emailId;
|
||||||
final String? textBody;
|
final String? textBody;
|
||||||
final String? htmlBody;
|
final String? htmlBody;
|
||||||
final List<EmailAttachment> attachments;
|
final List<EmailAttachment> attachments;
|
||||||
final List<EmailHeader> headers;
|
final List<EmailHeader> headers;
|
||||||
|
final MimePart? mimeTree;
|
||||||
|
|
||||||
const EmailBody({
|
const EmailBody({
|
||||||
required this.emailId,
|
required this.emailId,
|
||||||
@@ -245,6 +278,7 @@ class EmailBody {
|
|||||||
this.htmlBody,
|
this.htmlBody,
|
||||||
required this.attachments,
|
required this.attachments,
|
||||||
this.headers = const [],
|
this.headers = const [],
|
||||||
|
this.mimeTree,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class EmailNote {
|
||||||
|
final String id; // UUID (X-SharedInbox-Note-Id)
|
||||||
|
final String accountId;
|
||||||
|
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
|
||||||
|
final String noteText;
|
||||||
|
final String serverId; // IMAP UID (as string) or JMAP email ID
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const EmailNote({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.messageId,
|
||||||
|
required this.noteText,
|
||||||
|
required this.serverId,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
enum MenuPosition { bottom, top }
|
||||||
|
|
||||||
|
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||||
|
|
||||||
|
enum PrefetchMode {
|
||||||
|
disabled,
|
||||||
|
wifiOnly,
|
||||||
|
always;
|
||||||
|
|
||||||
|
static PrefetchMode fromString(String? value) {
|
||||||
|
return PrefetchMode.values.firstWhere(
|
||||||
|
(e) => e.name == value,
|
||||||
|
orElse: () => PrefetchMode.wifiOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserPreferences {
|
||||||
|
const UserPreferences({
|
||||||
|
this.menuPosition = MenuPosition.bottom,
|
||||||
|
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||||
|
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||||
|
this.prefetchMode = PrefetchMode.wifiOnly,
|
||||||
|
this.bodyCacheLimitMb = 100,
|
||||||
|
});
|
||||||
|
final MenuPosition menuPosition;
|
||||||
|
final MenuPosition mailViewButtonPosition;
|
||||||
|
final AfterMailViewAction afterMailViewAction;
|
||||||
|
final PrefetchMode prefetchMode;
|
||||||
|
final int bodyCacheLimitMb;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
abstract class EmailRepository {
|
abstract class EmailRepository {
|
||||||
@@ -15,6 +16,10 @@ abstract class EmailRepository {
|
|||||||
int limit = 50,
|
int limit = 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Returns threads from the INBOX mailbox of every account, sorted by latest
|
||||||
|
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
|
||||||
|
|
||||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||||
Stream<List<Email>> observeEmailsInThread(
|
Stream<List<Email>> observeEmailsInThread(
|
||||||
String accountId,
|
String accountId,
|
||||||
@@ -41,6 +46,10 @@ abstract class EmailRepository {
|
|||||||
/// return the cached path without a network round-trip.
|
/// return the cached path without a network round-trip.
|
||||||
Future<String> downloadAttachment(String emailId, EmailAttachment attachment);
|
Future<String> downloadAttachment(String emailId, EmailAttachment attachment);
|
||||||
|
|
||||||
|
/// Fetches the original RFC 2822 message from the server as a raw string.
|
||||||
|
/// Always performs a live network request — the raw message is not cached.
|
||||||
|
Future<String> fetchRawRfc822(String emailId);
|
||||||
|
|
||||||
/// Returns emails in [mailboxPath] whose subject or body contain [query].
|
/// Returns emails in [mailboxPath] whose subject or body contain [query].
|
||||||
/// Results come from the server (IMAP SEARCH) and are not cached.
|
/// Results come from the server (IMAP SEARCH) and are not cached.
|
||||||
Future<List<Email>> searchEmails(
|
Future<List<Email>> searchEmails(
|
||||||
@@ -50,13 +59,27 @@ abstract class EmailRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
||||||
/// if null) by subject and preview. Fast, works offline.
|
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||||
|
|
||||||
|
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
);
|
||||||
|
|
||||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||||
|
|
||||||
|
/// Returns unique email addresses from the local cache whose email or display
|
||||||
|
/// name contains [query]. Results are deduplicated and capped at [limit].
|
||||||
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
String? accountId,
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
});
|
||||||
|
|
||||||
/// Sends any queued local mutations for [accountId] to the server.
|
/// Sends any queued local mutations for [accountId] to the server.
|
||||||
/// Returns the number of changes successfully applied.
|
/// Returns the number of changes successfully applied.
|
||||||
Future<int> flushPendingChanges(String accountId, String password);
|
Future<int> flushPendingChanges(String accountId, String password);
|
||||||
@@ -87,6 +110,17 @@ abstract class EmailRepository {
|
|||||||
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
||||||
Future<void> restoreEmails(List<Email> emails);
|
Future<void> restoreEmails(List<Email> emails);
|
||||||
|
|
||||||
|
/// Finds an email in [accountId]'s mailboxes by its RFC 2822 Message-ID header.
|
||||||
|
/// Returns null if not found. Used during undo to locate an email after its
|
||||||
|
/// IMAP UID changed (e.g. after a server-applied move assigned a new UID).
|
||||||
|
Future<Email?> findEmailByMessageId(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
||||||
|
/// been processed yet. Records each processed email in LocalSieveApplied so
|
||||||
|
/// the same email is never filtered twice (across restarts or re-syncs).
|
||||||
|
/// Returns the number of emails where a rule matched and an action was taken.
|
||||||
|
Future<int> applySieveRules(String accountId);
|
||||||
|
|
||||||
/// Emits the accountId whenever a new change is enqueued locally.
|
/// Emits the accountId whenever a new change is enqueued locally.
|
||||||
/// Used by AccountSyncManager to trigger an immediate flush.
|
/// Used by AccountSyncManager to trigger an immediate flush.
|
||||||
Stream<String> get onChangesQueued;
|
Stream<String> get onChangesQueued;
|
||||||
|
|||||||
@@ -11,4 +11,17 @@ abstract class MailboxRepository {
|
|||||||
|
|
||||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||||
Future<void> clearForResync(String accountId);
|
Future<void> clearForResync(String accountId);
|
||||||
|
|
||||||
|
/// Creates a new mailbox named [name] for [accountId] and tags it with
|
||||||
|
/// [role] in the local database. For JMAP accounts the role is also sent
|
||||||
|
/// to the server. Returns the newly created [Mailbox].
|
||||||
|
Future<Mailbox> createMailboxWithRole(
|
||||||
|
String accountId,
|
||||||
|
String name,
|
||||||
|
String role,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a new mailbox named [name] for [accountId] without a special role.
|
||||||
|
/// Returns the newly created [Mailbox].
|
||||||
|
Future<Mailbox> createMailbox(String accountId, String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
|
||||||
|
abstract class NoteRepository {
|
||||||
|
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Fetches notes from the server into the local cache.
|
||||||
|
Future<void> syncNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Creates a new note on the server and caches it locally.
|
||||||
|
Future<void> addNote(String accountId, String messageId, String text);
|
||||||
|
|
||||||
|
/// Deletes a note from the server and removes it from the local cache.
|
||||||
|
Future<void> deleteNote(String noteId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
|
||||||
|
/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing.
|
||||||
|
abstract class ShareKeyRepository {
|
||||||
|
/// Generates a new key pair and persists it with a 20-minute expiry.
|
||||||
|
Future<ShareKeyMaterial> createKeyPair();
|
||||||
|
|
||||||
|
/// Returns the key pair whose ID matches [keyId], or null if not found /
|
||||||
|
/// expired.
|
||||||
|
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ class MailboxSyncStats {
|
|||||||
required this.fetched,
|
required this.fetched,
|
||||||
required this.skipped,
|
required this.skipped,
|
||||||
required this.bytesTransferred,
|
required this.bytesTransferred,
|
||||||
|
this.duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String mailboxPath;
|
final String mailboxPath;
|
||||||
final int fetched;
|
final int fetched;
|
||||||
final int skipped;
|
final int skipped;
|
||||||
final int bytesTransferred;
|
final int bytesTransferred;
|
||||||
|
final Duration? duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncLogEntry {
|
class SyncLogEntry {
|
||||||
@@ -17,6 +19,8 @@ class SyncLogEntry {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.result,
|
required this.result,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.stackTrace,
|
||||||
|
this.isPermanent = false,
|
||||||
required this.protocol,
|
required this.protocol,
|
||||||
required this.emailsFetched,
|
required this.emailsFetched,
|
||||||
required this.emailsSkipped,
|
required this.emailsSkipped,
|
||||||
@@ -32,6 +36,8 @@ class SyncLogEntry {
|
|||||||
final int id;
|
final int id;
|
||||||
final String result; // 'ok' or 'error'
|
final String result; // 'ok' or 'error'
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final String? stackTrace;
|
||||||
|
final bool isPermanent;
|
||||||
final String protocol; // 'imap' or 'jmap'
|
final String protocol; // 'imap' or 'jmap'
|
||||||
final int emailsFetched;
|
final int emailsFetched;
|
||||||
final int emailsSkipped;
|
final int emailsSkipped;
|
||||||
@@ -52,6 +58,8 @@ abstract class SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -79,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
|
|
||||||
|
abstract class UserPreferencesRepository {
|
||||||
|
Stream<UserPreferences> observePreferences();
|
||||||
|
Future<void> updateMenuPosition(MenuPosition position);
|
||||||
|
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||||
|
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||||
|
Future<void> updatePrefetchMode(PrefetchMode mode);
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb);
|
||||||
|
|
||||||
|
Stream<List<String>> observeTrustedImageSenders();
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail);
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
|
|
||||||
|
/// Prefetches email bodies in the background and enforces a local cache size
|
||||||
|
/// limit by evicting the oldest cached bodies when the limit is exceeded.
|
||||||
|
class BodyCacheService {
|
||||||
|
BodyCacheService(this._db, this._accountRepo);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accountRepo;
|
||||||
|
|
||||||
|
static const _batchSize = 20;
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
final prefs = await (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)).getSingleOrNull();
|
||||||
|
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
|
||||||
|
final limitBytes = limitMb * 1024 * 1024;
|
||||||
|
|
||||||
|
await _evictIfNeeded(limitBytes);
|
||||||
|
|
||||||
|
final candidates = await _fetchCandidates();
|
||||||
|
if (candidates.isEmpty) return;
|
||||||
|
|
||||||
|
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
|
||||||
|
|
||||||
|
for (final emailId in candidates) {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize >= limitBytes) break;
|
||||||
|
try {
|
||||||
|
await emailRepo.getEmailBody(emailId);
|
||||||
|
} catch (_) {
|
||||||
|
// Skip emails that fail to fetch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictIfNeeded(int limitBytes) async {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize <= limitBytes) return;
|
||||||
|
|
||||||
|
final bodies = await (_db.select(_db.emailBodies)
|
||||||
|
..where((t) => t.cachedAt.isNotNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
var remaining = currentSize;
|
||||||
|
for (final body in bodies) {
|
||||||
|
if (remaining <= limitBytes) break;
|
||||||
|
final bodySize =
|
||||||
|
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
|
||||||
|
await (_db.delete(_db.emailBodies)
|
||||||
|
..where((t) => t.emailId.equals(body.emailId)))
|
||||||
|
.go();
|
||||||
|
remaining -= bodySize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _getCacheSizeBytes() async {
|
||||||
|
final result = await _db
|
||||||
|
.customSelect(
|
||||||
|
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
|
||||||
|
)
|
||||||
|
.getSingle();
|
||||||
|
return result.read<int>('total');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _fetchCandidates() async {
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
'SELECT e.id FROM emails e '
|
||||||
|
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
|
||||||
|
'WHERE eb.email_id IS NULL '
|
||||||
|
'ORDER BY e.received_at DESC '
|
||||||
|
'LIMIT ?',
|
||||||
|
variables: [Variable.withInt(_batchSize)],
|
||||||
|
).get();
|
||||||
|
return rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,40 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
const _kChannelId = 'new_mail';
|
const _kChannelId = 'new_mail';
|
||||||
const _kChannelName = 'New mail';
|
const _kChannelName = 'New mail';
|
||||||
|
|
||||||
final _plugin = FlutterLocalNotificationsPlugin();
|
final _plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
Future<void> initNotifications() async {
|
Future<void> initNotifications() async {
|
||||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
try {
|
||||||
await _plugin.initialize(
|
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
const InitializationSettings(android: android),
|
await _plugin.initialize(
|
||||||
onDidReceiveNotificationResponse: (_) {},
|
settings: const InitializationSettings(android: android),
|
||||||
);
|
onDidReceiveNotificationResponse: (_) {},
|
||||||
await _plugin
|
);
|
||||||
.resolvePlatformSpecificImplementation<
|
await _plugin
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
.resolvePlatformSpecificImplementation<
|
||||||
?.requestNotificationsPermission();
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestNotificationsPermission();
|
||||||
|
_initialized = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; notifications silently disabled.
|
||||||
|
} catch (_) {
|
||||||
|
// Unexpected initialization failure; notifications silently disabled.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showNewMailNotification(String accountEmail) async {
|
Future<void> showNewMailNotification(String accountEmail) async {
|
||||||
if (!Platform.isAndroid) return;
|
if (!Platform.isAndroid || !_initialized) return;
|
||||||
await _plugin.show(
|
await _plugin.show(
|
||||||
accountEmail.hashCode & 0x7FFFFFFF,
|
id: accountEmail.hashCode & 0x7FFFFFFF,
|
||||||
'New mail',
|
title: 'New mail',
|
||||||
accountEmail,
|
body: accountEmail,
|
||||||
const NotificationDetails(
|
notificationDetails: const NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
_kChannelId,
|
_kChannelId,
|
||||||
_kChannelName,
|
_kChannelName,
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
const _pubKeyPrefix = 'sharedinbox.de:pubkey:v1:';
|
||||||
|
const _encAccountsPrefix = 'sharedinbox.de:encrypted-accounts:v1:';
|
||||||
|
|
||||||
|
// ECIES wire sizes (bytes).
|
||||||
|
const _keyIdLen = 16;
|
||||||
|
const _pubKeyLen = 32;
|
||||||
|
const _nonceLen = 12;
|
||||||
|
const _macLen = 16;
|
||||||
|
|
||||||
|
/// Describes a freshly generated key pair before it is written to the database.
|
||||||
|
class ShareKeyMaterial {
|
||||||
|
const ShareKeyMaterial({
|
||||||
|
required this.keyId,
|
||||||
|
required this.publicKeyBytes,
|
||||||
|
required this.privateKeyBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Random 16-byte identifier (hex-encoded when stored / included in QR).
|
||||||
|
final Uint8List keyId;
|
||||||
|
|
||||||
|
/// X25519 public key, 32 bytes.
|
||||||
|
final Uint8List publicKeyBytes;
|
||||||
|
|
||||||
|
/// X25519 private key, 32 bytes.
|
||||||
|
final Uint8List privateKeyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An account + password pair, used in the plaintext payload before encryption.
|
||||||
|
class AccountPayload {
|
||||||
|
const AccountPayload({required this.accountJson, required this.password});
|
||||||
|
|
||||||
|
final Map<String, dynamic> accountJson;
|
||||||
|
final String password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure-Dart cryptographic helpers for the secure account-sharing flow.
|
||||||
|
///
|
||||||
|
/// Protocol:
|
||||||
|
/// Receiver generates an X25519 key pair with 20-minute lifetime and shows
|
||||||
|
/// its public key as a QR code. The sender scans that QR, encrypts the
|
||||||
|
/// selected account(s) using ECIES (X25519-ECDH + HKDF-SHA256 + AES-256-GCM)
|
||||||
|
/// and shows the encrypted payload as a QR code. The receiver scans that QR,
|
||||||
|
/// looks up the private key by the embedded key-ID, and decrypts.
|
||||||
|
class ShareEncryptionService {
|
||||||
|
static final _x25519 = X25519();
|
||||||
|
static final _aesGcm = AesGcm.with256bits();
|
||||||
|
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
|
static final _rng = Random.secure();
|
||||||
|
|
||||||
|
// ── Key generation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static Future<ShareKeyMaterial> generateKeyPair() async {
|
||||||
|
final keyId = Uint8List(_keyIdLen);
|
||||||
|
for (var i = 0; i < _keyIdLen; i++) {
|
||||||
|
keyId[i] = _rng.nextInt(256);
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyPair = await _x25519.newKeyPair();
|
||||||
|
final pub = await keyPair.extractPublicKey();
|
||||||
|
final priv = await keyPair.extractPrivateKeyBytes();
|
||||||
|
|
||||||
|
return ShareKeyMaterial(
|
||||||
|
keyId: keyId,
|
||||||
|
publicKeyBytes: Uint8List.fromList(pub.bytes),
|
||||||
|
privateKeyBytes: Uint8List.fromList(priv),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public-key QR encoding / parsing ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Encodes the receiver's public key as a QR-code string.
|
||||||
|
///
|
||||||
|
/// Format: `sharedinbox.de:pubkey:v1:<base64(keyId[16] || pubKey[32])>`
|
||||||
|
static String encodePublicKeyQr(Uint8List keyId, Uint8List publicKeyBytes) {
|
||||||
|
assert(keyId.length == _keyIdLen);
|
||||||
|
assert(publicKeyBytes.length == _pubKeyLen);
|
||||||
|
final data = Uint8List(_keyIdLen + _pubKeyLen)
|
||||||
|
..setAll(0, keyId)
|
||||||
|
..setAll(_keyIdLen, publicKeyBytes);
|
||||||
|
return '$_pubKeyPrefix${base64.encode(data)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a public-key QR string. Returns null if the format is invalid.
|
||||||
|
static ({Uint8List keyId, Uint8List publicKeyBytes})? parsePublicKeyQr(
|
||||||
|
String s,
|
||||||
|
) {
|
||||||
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
|
try {
|
||||||
|
final data = Uint8List.fromList(
|
||||||
|
base64.decode(s.substring(_pubKeyPrefix.length)),
|
||||||
|
);
|
||||||
|
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||||
|
return (
|
||||||
|
keyId: data.sublist(0, _keyIdLen),
|
||||||
|
publicKeyBytes: data.sublist(_keyIdLen),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encryption ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Encrypts [accounts] for the given recipient key pair using ECIES.
|
||||||
|
///
|
||||||
|
/// Returns the QR-code string to show on the sender device.
|
||||||
|
///
|
||||||
|
/// Wire format (base64-encoded):
|
||||||
|
/// keyId[16] || ephPubKey[32] || nonce[12] || ciphertext || mac[16]
|
||||||
|
static Future<String> encryptAccounts({
|
||||||
|
required Uint8List recipientKeyId,
|
||||||
|
required Uint8List recipientPublicKeyBytes,
|
||||||
|
required List<AccountPayload> accounts,
|
||||||
|
}) async {
|
||||||
|
// Build plaintext JSON.
|
||||||
|
final plaintext = utf8.encode(
|
||||||
|
jsonEncode({
|
||||||
|
'v': 2,
|
||||||
|
'issuedAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'accounts': accounts
|
||||||
|
.map((a) => {'account': a.accountJson, 'password': a.password})
|
||||||
|
.toList(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ephemeral sender key pair for forward-secrecy.
|
||||||
|
final ephKeyPair = await _x25519.newKeyPair();
|
||||||
|
final ephPub = await ephKeyPair.extractPublicKey();
|
||||||
|
|
||||||
|
// ECDH: shared secret = X25519(ephPriv, recipientPub).
|
||||||
|
final sharedSecret = await _x25519.sharedSecretKey(
|
||||||
|
keyPair: ephKeyPair,
|
||||||
|
remotePublicKey: SimplePublicKey(
|
||||||
|
recipientPublicKeyBytes,
|
||||||
|
type: KeyPairType.x25519,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive AES key via HKDF-SHA256.
|
||||||
|
final aesKey = await _hkdf.deriveKey(
|
||||||
|
secretKey: sharedSecret,
|
||||||
|
nonce: recipientKeyId,
|
||||||
|
info: utf8.encode('sharedinbox-account-transfer'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypt with AES-256-GCM.
|
||||||
|
final nonce = Uint8List(_nonceLen);
|
||||||
|
for (var i = 0; i < _nonceLen; i++) {
|
||||||
|
nonce[i] = _rng.nextInt(256);
|
||||||
|
}
|
||||||
|
|
||||||
|
final box = await _aesGcm.encrypt(
|
||||||
|
plaintext,
|
||||||
|
secretKey: aesKey,
|
||||||
|
nonce: nonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pack wire format.
|
||||||
|
final ephPubBytes = Uint8List.fromList(ephPub.bytes);
|
||||||
|
final cipherBytes = Uint8List.fromList(box.cipherText);
|
||||||
|
final macBytes = Uint8List.fromList(box.mac.bytes);
|
||||||
|
|
||||||
|
final out = Uint8List(
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen,
|
||||||
|
)
|
||||||
|
..setAll(0, recipientKeyId)
|
||||||
|
..setAll(_keyIdLen, ephPubBytes)
|
||||||
|
..setAll(_keyIdLen + _pubKeyLen, nonce)
|
||||||
|
..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes)
|
||||||
|
..setAll(
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length,
|
||||||
|
macBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return '$_encAccountsPrefix${base64.encode(out)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decryption ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parses and decrypts an encrypted-accounts QR string.
|
||||||
|
///
|
||||||
|
/// Throws [FormatException] if the format is invalid.
|
||||||
|
/// Throws [SecretBoxAuthenticationError] if authentication fails (tampered).
|
||||||
|
static Future<List<AccountPayload>> decryptAccounts({
|
||||||
|
required String qrString,
|
||||||
|
required Uint8List privateKeyBytes,
|
||||||
|
required Uint8List publicKeyBytes,
|
||||||
|
required Uint8List keyId,
|
||||||
|
}) async {
|
||||||
|
if (!qrString.startsWith(_encAccountsPrefix)) {
|
||||||
|
throw const FormatException('Not an encrypted-accounts QR code');
|
||||||
|
}
|
||||||
|
|
||||||
|
final Uint8List data;
|
||||||
|
try {
|
||||||
|
data = Uint8List.fromList(
|
||||||
|
base64.decode(qrString.substring(_encAccountsPrefix.length)),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
throw const FormatException('Invalid base64 in encrypted-accounts QR');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum: keyId + ephPubKey + nonce + mac (no ciphertext is valid but odd).
|
||||||
|
if (data.length < _keyIdLen + _pubKeyLen + _nonceLen + _macLen) {
|
||||||
|
throw const FormatException('Encrypted-accounts payload too short');
|
||||||
|
}
|
||||||
|
|
||||||
|
final embeddedKeyId = data.sublist(0, _keyIdLen);
|
||||||
|
// Verify that this payload was encrypted for the right key pair.
|
||||||
|
for (var i = 0; i < _keyIdLen; i++) {
|
||||||
|
if (embeddedKeyId[i] != keyId[i]) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Key ID mismatch — please scan a fresh public-key QR code',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ephPubBytes = data.sublist(_keyIdLen, _keyIdLen + _pubKeyLen);
|
||||||
|
final nonce = data.sublist(
|
||||||
|
_keyIdLen + _pubKeyLen,
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen,
|
||||||
|
);
|
||||||
|
final cipherText = data.sublist(
|
||||||
|
_keyIdLen + _pubKeyLen + _nonceLen,
|
||||||
|
data.length - _macLen,
|
||||||
|
);
|
||||||
|
final mac = data.sublist(data.length - _macLen);
|
||||||
|
|
||||||
|
// Reconstruct key pair.
|
||||||
|
final keyPair = SimpleKeyPairData(
|
||||||
|
privateKeyBytes,
|
||||||
|
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519),
|
||||||
|
type: KeyPairType.x25519,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ECDH.
|
||||||
|
final sharedSecret = await _x25519.sharedSecretKey(
|
||||||
|
keyPair: keyPair,
|
||||||
|
remotePublicKey: SimplePublicKey(ephPubBytes, type: KeyPairType.x25519),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-derive AES key.
|
||||||
|
final aesKey = await _hkdf.deriveKey(
|
||||||
|
secretKey: sharedSecret,
|
||||||
|
nonce: keyId,
|
||||||
|
info: utf8.encode('sharedinbox-account-transfer'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt — throws SecretBoxAuthenticationError if tampered.
|
||||||
|
final plaintext = await _aesGcm.decrypt(
|
||||||
|
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||||
|
secretKey: aesKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse JSON.
|
||||||
|
final Map<String, dynamic> json;
|
||||||
|
try {
|
||||||
|
json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
throw const FormatException('Decrypted payload is not valid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((json['v'] as int?) != 2) {
|
||||||
|
throw const FormatException('Unsupported encrypted-accounts version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify issuedAt is within 20 minutes.
|
||||||
|
final issuedAtRaw = json['issuedAt'] as String?;
|
||||||
|
if (issuedAtRaw != null) {
|
||||||
|
final issuedAt = DateTime.tryParse(issuedAtRaw);
|
||||||
|
if (issuedAt != null) {
|
||||||
|
final age = DateTime.now().toUtc().difference(issuedAt.toUtc());
|
||||||
|
if (age.abs() > const Duration(minutes: 20)) {
|
||||||
|
throw const FormatException(
|
||||||
|
'The encrypted payload has expired (older than 20 minutes)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawAccounts = json['accounts'] as List<dynamic>;
|
||||||
|
return rawAccounts.map((entry) {
|
||||||
|
final m = entry as Map<String, dynamic>;
|
||||||
|
return AccountPayload(
|
||||||
|
accountJson: m['account'] as Map<String, dynamic>,
|
||||||
|
password: m['password'] as String,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
class UndoService extends StateNotifier<List<UndoAction>> {
|
class UndoService extends Notifier<List<UndoAction>> {
|
||||||
UndoService(this._ref) : super([]);
|
|
||||||
|
|
||||||
final Ref _ref;
|
|
||||||
static const int _maxHistory = 10;
|
static const int _maxHistory = 10;
|
||||||
|
|
||||||
// Resolves once init() has loaded persisted history. Default to an already-
|
// Resolves once build() has loaded persisted history.
|
||||||
// resolved future so operations are safe even if init() is never called.
|
late Future<void> _ready;
|
||||||
Future<void> _ready = Future.value();
|
|
||||||
|
|
||||||
Future<void> init() async {
|
@override
|
||||||
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
|
List<UndoAction> build() {
|
||||||
if (mounted) state = history;
|
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||||
|
if (ref.mounted) state = history;
|
||||||
});
|
});
|
||||||
await _ready;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Waits for the persisted history to finish loading. Called by tests to
|
||||||
|
/// ensure the provider is ready before asserting state.
|
||||||
|
Future<void> init() => _ready;
|
||||||
|
|
||||||
Future<void> pushAction(UndoAction action) async {
|
Future<void> pushAction(UndoAction action) async {
|
||||||
await _ready;
|
await _ready;
|
||||||
final newList = [...state, action];
|
final newList = [...state, action];
|
||||||
if (newList.length > _maxHistory) {
|
if (newList.length > _maxHistory) {
|
||||||
final removed = newList.removeAt(0);
|
final removed = newList.removeAt(0);
|
||||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(removed.id));
|
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
||||||
}
|
}
|
||||||
state = newList;
|
state = newList;
|
||||||
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
|
await ref.read(undoRepositoryProvider).saveAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await _ready;
|
await _ready;
|
||||||
state = [];
|
state = [];
|
||||||
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
|
unawaited(ref.read(undoRepositoryProvider).clearHistory());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> undo({String? actionId}) async {
|
Future<void> undo({String? actionId}) async {
|
||||||
@@ -45,19 +46,19 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
final UndoAction action;
|
final UndoAction action;
|
||||||
if (actionId == null) {
|
if (actionId == null) {
|
||||||
action = state.last;
|
action = state.last;
|
||||||
state = state.sublist(0, state.length - 1);
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
action = state.firstWhere((a) => a.id == actionId);
|
action = state.firstWhere((a) => a.id == actionId);
|
||||||
state = state.where((a) => a.id != actionId).toList();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return; // Action not found
|
return; // Action not found
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(action.id));
|
// Keep the original entry in state and DB so the user can see what
|
||||||
|
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
|
||||||
|
// the local change). The inverse action added below allows undoing the undo.
|
||||||
|
|
||||||
final repo = _ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
for (final id in action.emailIds) {
|
for (final id in action.emailIds) {
|
||||||
// 1. Try to cancel the original change (if not started yet).
|
// 1. Try to cancel the original change (if not started yet).
|
||||||
@@ -70,10 +71,22 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
? null
|
? null
|
||||||
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
||||||
|
|
||||||
// 2. If row is missing (hard delete), restore it first.
|
// 2. Resolve the current DB row for the email.
|
||||||
// We restore it at its CURRENT state (where it is on the server,
|
// For IMAP, after a server-applied move the email gets a new UID, so
|
||||||
// or where it was moving to).
|
// the original id ('accountId:oldUid') no longer exists. Look it up by
|
||||||
if (original != null) {
|
// Message-ID so we use the correct UID in the pending change.
|
||||||
|
var currentEmail = await repo.getEmail(id);
|
||||||
|
if (currentEmail == null && original?.messageId != null) {
|
||||||
|
currentEmail = await repo.findEmailByMessageId(
|
||||||
|
action.accountId,
|
||||||
|
original!.messageId!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final currentId = currentEmail?.id ?? id;
|
||||||
|
|
||||||
|
// 3. If the row is absent (hard delete or UID changed after sync),
|
||||||
|
// restore it from the saved snapshot so moveEmail can find it.
|
||||||
|
if (currentEmail == null && original != null) {
|
||||||
final currentPath = cancelled
|
final currentPath = cancelled
|
||||||
? action.sourceMailboxPath
|
? action.sourceMailboxPath
|
||||||
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
||||||
@@ -82,19 +95,40 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Move it back to source.
|
// 4. Move it back to source.
|
||||||
// This updates local DB optimistically and (if not cancelled) enqueues
|
// This updates local DB optimistically and (if not cancelled) enqueues
|
||||||
// a reverse move on the server.
|
// a reverse move on the server using the correct UID.
|
||||||
await repo.moveEmail(id, action.sourceMailboxPath);
|
await repo.moveEmail(currentId, action.sourceMailboxPath);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
// 4. If we successfully cancelled the original, the reverse move
|
// 5. If we successfully cancelled the original, the reverse move
|
||||||
// we just enqueued is redundant.
|
// we just enqueued is redundant.
|
||||||
await repo.cancelPendingChange(id, 'move');
|
await repo.cancelPendingChange(currentId, 'move');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Best effort.
|
// Best effort.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a reverse action so the undo log always retains a record and the
|
||||||
|
// user can re-apply the original operation. sourceMailboxPath on the
|
||||||
|
// inverse is the original destination (e.g. Trash) so that undoing the
|
||||||
|
// inverse moves emails back there; destinationMailboxPath records where
|
||||||
|
// they are now (the original source, e.g. INBOX).
|
||||||
|
final inverseDest = action.destinationMailboxPath;
|
||||||
|
if (inverseDest != null) {
|
||||||
|
await pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: '${action.id}-inv',
|
||||||
|
accountId: action.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: action.emailIds,
|
||||||
|
sourceMailboxPath: inverseDest,
|
||||||
|
destinationMailboxPath: action.sourceMailboxPath,
|
||||||
|
originalEmails: action.originalEmails,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
const _kAppVersion = String.fromEnvironment('GIT_HASH');
|
||||||
|
const _kLatestJsonUrl = 'https://sharedinbox.de/latest.json';
|
||||||
|
|
||||||
|
class UpdateInfo {
|
||||||
|
const UpdateInfo({required this.latestVersion, required this.downloadUrl});
|
||||||
|
|
||||||
|
final String latestVersion;
|
||||||
|
final String downloadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an [UpdateInfo] when a newer Linux or Windows version is available,
|
||||||
|
/// or null if the app is up to date, the version is unknown, or the platform
|
||||||
|
/// is not a supported desktop.
|
||||||
|
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||||
|
final platformKey = Platform.isLinux
|
||||||
|
? 'linux'
|
||||||
|
: Platform.isWindows
|
||||||
|
? 'windows'
|
||||||
|
: null;
|
||||||
|
if (platformKey == null || _kAppVersion.isEmpty) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resp = await http
|
||||||
|
.get(Uri.parse(_kLatestJsonUrl))
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
if (resp.statusCode != 200) return null;
|
||||||
|
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||||
|
final latest = json['version'] as String?;
|
||||||
|
final url = json[platformKey] as String?;
|
||||||
|
if (latest == null || url == null) return null;
|
||||||
|
if (latest == _kAppVersion) return null;
|
||||||
|
return UpdateInfo(latestVersion: latest, downloadUrl: url);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
sealed class SieveAction {}
|
||||||
|
|
||||||
|
final class FileIntoAction extends SieveAction {
|
||||||
|
FileIntoAction(this.folder);
|
||||||
|
final String folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeepAction extends SieveAction {}
|
||||||
|
|
||||||
|
final class DiscardAction extends SieveAction {}
|
||||||
|
|
||||||
|
final class MarkAsSeenAction extends SieveAction {}
|
||||||
|
|
||||||
|
final class FlagAction extends SieveAction {
|
||||||
|
FlagAction(this.flags);
|
||||||
|
final List<String> flags;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
sealed class SieveCondition {}
|
||||||
|
|
||||||
|
final class HeaderCondition extends SieveCondition {
|
||||||
|
HeaderCondition(this.headers, this.matchType, this.keyList);
|
||||||
|
final List<String> headers;
|
||||||
|
final String matchType; // ':contains', ':is', ':matches'
|
||||||
|
final List<String> keyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SizeCondition extends SieveCondition {
|
||||||
|
SizeCondition(this.comparison, this.bytes);
|
||||||
|
final String comparison; // ':over' or ':under'
|
||||||
|
final int bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||||
|
|
||||||
|
/// A lightweight email representation used by [SieveInterpreter].
|
||||||
|
/// Header names are lower-cased.
|
||||||
|
class SieveEmailContext {
|
||||||
|
const SieveEmailContext({required this.headers, this.sizeBytes = 0});
|
||||||
|
|
||||||
|
final Map<String, List<String>> headers;
|
||||||
|
final int sizeBytes;
|
||||||
|
|
||||||
|
List<String> getHeader(String name) =>
|
||||||
|
headers[name.toLowerCase()] ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the outcome of running a Sieve script against one email.
|
||||||
|
class SieveExecutionContext {
|
||||||
|
bool isCancelled = false;
|
||||||
|
Set<String> targetFolders = {};
|
||||||
|
Set<String> flagsToAdd = {};
|
||||||
|
bool keepInInbox = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates a compiled list of [SieveRule]s against a [SieveEmailContext].
|
||||||
|
class SieveInterpreter {
|
||||||
|
/// Executes [rules] and returns the resulting [SieveExecutionContext].
|
||||||
|
///
|
||||||
|
/// Rules produced by [SieveParser] may carry a [SieveRule.branchGroupId]
|
||||||
|
/// to represent if/elsif/else chains; at most one branch per group fires.
|
||||||
|
SieveExecutionContext execute(
|
||||||
|
List<SieveRule> rules,
|
||||||
|
SieveEmailContext email,
|
||||||
|
) {
|
||||||
|
final ctx = SieveExecutionContext();
|
||||||
|
final firedGroups = <int>{};
|
||||||
|
|
||||||
|
for (final rule in rules) {
|
||||||
|
if (ctx.isCancelled) break;
|
||||||
|
|
||||||
|
final groupId = rule.branchGroupId;
|
||||||
|
if (groupId != null && firedGroups.contains(groupId)) continue;
|
||||||
|
|
||||||
|
bool matches;
|
||||||
|
if (rule.isElseBranch) {
|
||||||
|
matches = true; // else fires unconditionally (group not yet consumed)
|
||||||
|
} else {
|
||||||
|
matches = _evaluateConditions(rule, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
_applyActions(rule.actions, ctx);
|
||||||
|
if (groupId != null) firedGroups.add(groupId);
|
||||||
|
if (ctx.isCancelled) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implicit keep: if no fileinto/discard was reached, email stays in inbox.
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evaluateConditions(SieveRule rule, SieveEmailContext email) {
|
||||||
|
if (rule.conditions.isEmpty) return true;
|
||||||
|
return switch (rule.joinType) {
|
||||||
|
'allof' => rule.conditions.every((c) => _evalCondition(c, email)),
|
||||||
|
'anyof' => rule.conditions.any((c) => _evalCondition(c, email)),
|
||||||
|
_ => rule.conditions.length == 1 &&
|
||||||
|
_evalCondition(rule.conditions.first, email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evalCondition(SieveCondition cond, SieveEmailContext email) {
|
||||||
|
return switch (cond) {
|
||||||
|
final HeaderCondition c => _evalHeader(c, email),
|
||||||
|
final SizeCondition c => _evalSize(c, email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evalHeader(HeaderCondition cond, SieveEmailContext email) {
|
||||||
|
for (final header in cond.headers) {
|
||||||
|
final values = email.getHeader(header);
|
||||||
|
for (final value in values) {
|
||||||
|
for (final key in cond.keyList) {
|
||||||
|
if (_matchString(value, cond.matchType, key)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _evalSize(SizeCondition cond, SieveEmailContext email) {
|
||||||
|
return switch (cond.comparison) {
|
||||||
|
':over' => email.sizeBytes > cond.bytes,
|
||||||
|
':under' => email.sizeBytes < cond.bytes,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchString(String value, String matchType, String key) {
|
||||||
|
final v = value.toLowerCase();
|
||||||
|
final k = key.toLowerCase();
|
||||||
|
return switch (matchType) {
|
||||||
|
':contains' => k.isEmpty || v.contains(k),
|
||||||
|
':is' => v == k,
|
||||||
|
':matches' => globMatch(v, k),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||||
|
for (final action in actions) {
|
||||||
|
switch (action) {
|
||||||
|
case final FileIntoAction a:
|
||||||
|
ctx.targetFolders.add(a.folder);
|
||||||
|
ctx.keepInInbox = false;
|
||||||
|
case DiscardAction():
|
||||||
|
ctx.isCancelled = true;
|
||||||
|
ctx.keepInInbox = false;
|
||||||
|
return;
|
||||||
|
case KeepAction():
|
||||||
|
ctx.keepInInbox = true;
|
||||||
|
case MarkAsSeenAction():
|
||||||
|
ctx.flagsToAdd.add(r'\Seen');
|
||||||
|
case final FlagAction a:
|
||||||
|
ctx.flagsToAdd.addAll(a.flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
|
||||||
|
/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s.
|
||||||
|
///
|
||||||
|
/// Supported commands: require, if, elsif, else, fileinto, keep, discard,
|
||||||
|
/// flag, setflag, addflag, stop.
|
||||||
|
/// Supported tests: header, address, size, exists, allof, anyof, not, true.
|
||||||
|
/// Supported match types: :contains, :is, :matches.
|
||||||
|
class SieveParser {
|
||||||
|
List<SieveRule> parse(String script) {
|
||||||
|
final scanner = _Scanner(script);
|
||||||
|
final rules = <SieveRule>[];
|
||||||
|
_parseStatements(scanner, rules);
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseStatements(_Scanner s, List<SieveRule> out) {
|
||||||
|
while (!s.isAtEnd) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.isAtEnd) break;
|
||||||
|
|
||||||
|
final word = s.peekWord();
|
||||||
|
if (word == null) break;
|
||||||
|
|
||||||
|
if (word == 'require') {
|
||||||
|
_parseRequire(s);
|
||||||
|
} else if (word == 'if') {
|
||||||
|
_parseIf(s, out);
|
||||||
|
} else if (word == 'elsif' || word == 'else') {
|
||||||
|
// Reached by _parseIf, should not appear at top level.
|
||||||
|
break;
|
||||||
|
} else if (word == '}') {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
final action = _tryParseAction(s);
|
||||||
|
if (action != null) {
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: 'single',
|
||||||
|
conditions: const [],
|
||||||
|
actions: [action],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
s.skipToNextSemicolon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseRequire(_Scanner s) {
|
||||||
|
s.expectWord('require');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
_parseStringOrList(s); // discard capability list
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monotonically increasing id shared per parse run, threaded via closure.
|
||||||
|
int _groupCounter = 0;
|
||||||
|
|
||||||
|
void _parseIf(_Scanner s, List<SieveRule> out) {
|
||||||
|
final groupId = ++_groupCounter;
|
||||||
|
|
||||||
|
s.expectWord('if');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final (joinType, conditions) = _parseTest(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final ifActions = _parseBlock(s);
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: joinType,
|
||||||
|
conditions: conditions,
|
||||||
|
actions: ifActions,
|
||||||
|
branchGroupId: groupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse zero or more elsif branches.
|
||||||
|
while (true) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peekWord() != 'elsif') break;
|
||||||
|
s.expectWord('elsif');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final (ej, ec) = _parseTest(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final elsifActions = _parseBlock(s);
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: ej,
|
||||||
|
conditions: ec,
|
||||||
|
actions: elsifActions,
|
||||||
|
branchGroupId: groupId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional else branch.
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peekWord() == 'else') {
|
||||||
|
s.expectWord('else');
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final elseActions = _parseBlock(s);
|
||||||
|
out.add(
|
||||||
|
SieveRule(
|
||||||
|
joinType: 'single',
|
||||||
|
conditions: const [],
|
||||||
|
actions: elseActions,
|
||||||
|
branchGroupId: groupId,
|
||||||
|
isElseBranch: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SieveAction> _parseBlock(_Scanner s) {
|
||||||
|
s.expectChar('{');
|
||||||
|
final blockRules = <SieveRule>[];
|
||||||
|
_parseStatements(s, blockRules);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar('}');
|
||||||
|
return blockRules.expand((r) => r.actions).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (joinType, conditions).
|
||||||
|
(String, List<SieveCondition>) _parseTest(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final word = s.peekWord();
|
||||||
|
|
||||||
|
if (word == 'allof' || word == 'anyof') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar('(');
|
||||||
|
final conditions = <SieveCondition>[];
|
||||||
|
while (true) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ')') break;
|
||||||
|
final (_, conds) = _parseTest(s);
|
||||||
|
conditions.addAll(conds);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ',') {
|
||||||
|
s.advance();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(')');
|
||||||
|
return (word!, conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cond = _parseSingleTest(s);
|
||||||
|
return ('single', cond != null ? [cond] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
SieveCondition? _parseSingleTest(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
|
||||||
|
if (word == 'not') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
// Negation is not represented in the flat rule model; the caller
|
||||||
|
// should handle the negated condition separately. For now we parse
|
||||||
|
// and return the inner condition unchanged (best-effort for this subset).
|
||||||
|
return _parseSingleTest(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'true') {
|
||||||
|
s.readWord();
|
||||||
|
return null; // no condition = always matches
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'header' || word == 'address') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final matchType = _parseMatchType(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
// Consume optional :comparator "..." tagged argument.
|
||||||
|
if (s.peekTaggedArg() == ':comparator') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
_parseStringOrList(s); // discard comparator value
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
}
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final keys = _parseStringOrList(s);
|
||||||
|
return HeaderCondition(headers, matchType, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'exists') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
// Represent exists as :contains "" so any non-empty value matches.
|
||||||
|
return HeaderCondition(headers, ':contains', const ['']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'size') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final comp = s.readTaggedArg(); // :over or :under
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final bytes = _parseSizeNumber(s);
|
||||||
|
return SizeCondition(comp, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown test — skip to closing paren or brace.
|
||||||
|
s.readWord();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseMatchType(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final tag = s.peekTaggedArg();
|
||||||
|
if (tag == ':contains' || tag == ':is' || tag == ':matches') {
|
||||||
|
s.readWord();
|
||||||
|
return tag!;
|
||||||
|
}
|
||||||
|
// Default per RFC 5228 is :is.
|
||||||
|
return ':is';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseStringOrList(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == '[') {
|
||||||
|
s.advance(); // consume '['
|
||||||
|
final items = <String>[];
|
||||||
|
while (true) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ']') {
|
||||||
|
s.advance();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
items.add(_parseString(s));
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == ',') {
|
||||||
|
s.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return [_parseString(s)];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseString(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == '"') {
|
||||||
|
return s.readQuotedString();
|
||||||
|
}
|
||||||
|
// Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2)
|
||||||
|
if (s.peekWord()?.toLowerCase() == 'text:') {
|
||||||
|
return s.readTextBlock();
|
||||||
|
}
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _parseSizeNumber(_Scanner s) {
|
||||||
|
final digits = s.readDigits();
|
||||||
|
final value = int.parse(digits);
|
||||||
|
final unit = s.peekSizeUnit();
|
||||||
|
if (unit != null) {
|
||||||
|
s.advance();
|
||||||
|
return switch (unit.toUpperCase()) {
|
||||||
|
'K' => value * 1024,
|
||||||
|
'M' => value * 1024 * 1024,
|
||||||
|
'G' => value * 1024 * 1024 * 1024,
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
SieveAction? _tryParseAction(_Scanner s) {
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
|
||||||
|
if (word == 'fileinto') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
final folder = _parseString(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return FileIntoAction(folder);
|
||||||
|
}
|
||||||
|
if (word == 'keep') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return KeepAction();
|
||||||
|
}
|
||||||
|
if (word == 'discard') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return DiscardAction();
|
||||||
|
}
|
||||||
|
if (word == 'stop') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return KeepAction(); // stop with no prior action = implicit keep
|
||||||
|
}
|
||||||
|
if (word == 'flag' || word == 'setflag' || word == 'addflag') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
// Optional variable name (string arg before the flag list).
|
||||||
|
final peek = s.peek();
|
||||||
|
List<String> flags;
|
||||||
|
if (peek == '"') {
|
||||||
|
final first = _parseString(s);
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
if (s.peek() == '[' || s.peek() == '"') {
|
||||||
|
// first was the variable name, next is the flag list
|
||||||
|
flags = _parseStringOrList(s);
|
||||||
|
} else {
|
||||||
|
flags = [first];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flags = _parseStringOrList(s);
|
||||||
|
}
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
if (flags.any(
|
||||||
|
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||||
|
)) {
|
||||||
|
return MarkAsSeenAction();
|
||||||
|
}
|
||||||
|
return FlagAction(flags);
|
||||||
|
}
|
||||||
|
if (word == 'mark') {
|
||||||
|
s.readWord();
|
||||||
|
s.skipWhitespaceAndComments();
|
||||||
|
s.expectChar(';');
|
||||||
|
return MarkAsSeenAction();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Low-level scanner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SieveParseException implements Exception {
|
||||||
|
SieveParseException(this.message);
|
||||||
|
final String message;
|
||||||
|
@override
|
||||||
|
String toString() => 'SieveParseException: $message';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Scanner {
|
||||||
|
_Scanner(this._src);
|
||||||
|
|
||||||
|
final String _src;
|
||||||
|
int _pos = 0;
|
||||||
|
|
||||||
|
int get position => _pos;
|
||||||
|
bool get isAtEnd => _pos >= _src.length;
|
||||||
|
String get remaining => _pos < _src.length ? _src.substring(_pos) : '';
|
||||||
|
|
||||||
|
String? peek() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
return _src[_pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
String advance() {
|
||||||
|
if (isAtEnd) throw SieveParseException('Unexpected end of input');
|
||||||
|
return _src[_pos++];
|
||||||
|
}
|
||||||
|
|
||||||
|
void skipWhitespaceAndComments() {
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||||
|
_pos++;
|
||||||
|
} else if (ch == '#') {
|
||||||
|
// Line comment — skip to end of line.
|
||||||
|
while (!isAtEnd && _src[_pos] != '\n') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||||
|
// Block comment.
|
||||||
|
_pos += 2;
|
||||||
|
while (_pos + 1 < _src.length) {
|
||||||
|
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||||
|
_pos += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peeks at the next word-like token (letters/digits/underscores/colons for
|
||||||
|
/// tagged args, and special single-char tokens like `{`, `}`, `;`).
|
||||||
|
String? peekWord() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) return ch;
|
||||||
|
if (ch == ':') {
|
||||||
|
// Tagged arg like :contains
|
||||||
|
final start = _pos;
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (end < _src.length && _isWordChar(_src[end])) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(start, end).toLowerCase();
|
||||||
|
}
|
||||||
|
if (_isWordChar(ch)) {
|
||||||
|
final start = _pos;
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (
|
||||||
|
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
|
||||||
|
// Include trailing colon for "text:" multiline token.
|
||||||
|
if (_src[end] == ':') {
|
||||||
|
end++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(start, end).toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readWord() {
|
||||||
|
final start = _pos;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) {
|
||||||
|
_pos++;
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
if (ch == ':') {
|
||||||
|
_pos++;
|
||||||
|
while (!isAtEnd && _isWordChar(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) {
|
||||||
|
if (_src[_pos] == ':') {
|
||||||
|
_pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _src.substring(start, _pos).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? peekTaggedArg() {
|
||||||
|
if (!isAtEnd && _src[_pos] == ':') return peekWord();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readTaggedArg() {
|
||||||
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
|
throw SieveParseException('Expected tagged argument at position $_pos');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? peekSizeUnit() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
final ch = _src[_pos].toUpperCase();
|
||||||
|
if (ch == 'K' || ch == 'M' || ch == 'G') return ch;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readDigits() {
|
||||||
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
|
throw SieveParseException('Expected number at position $_pos');
|
||||||
|
}
|
||||||
|
final start = _pos;
|
||||||
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
return _src.substring(start, _pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
String readQuotedString() {
|
||||||
|
if (_src[_pos] != '"') {
|
||||||
|
throw SieveParseException('Expected " at position $_pos');
|
||||||
|
}
|
||||||
|
_pos++; // skip opening quote
|
||||||
|
final buf = StringBuffer();
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == '"') {
|
||||||
|
_pos++;
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||||
|
_pos++;
|
||||||
|
buf.write(_src[_pos]);
|
||||||
|
_pos++;
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw SieveParseException('Unterminated string');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `text:` multi-line block (RFC 5228 §2.4.2).
|
||||||
|
/// Format: `text:\r\n<lines>\r\n.\r\n`
|
||||||
|
String readTextBlock() {
|
||||||
|
// Consume "text:"
|
||||||
|
while (!isAtEnd && _src[_pos] != ':') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (!isAtEnd) _pos++; // skip ':'
|
||||||
|
// Skip optional whitespace then newline.
|
||||||
|
while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||||
|
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
while (!isAtEnd) {
|
||||||
|
// Check for terminator: a lone "." on its own line.
|
||||||
|
if (_src[_pos] == '.' &&
|
||||||
|
(_pos + 1 >= _src.length ||
|
||||||
|
_src[_pos + 1] == '\r' ||
|
||||||
|
_src[_pos + 1] == '\n')) {
|
||||||
|
_pos++;
|
||||||
|
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||||
|
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.write(_src[_pos]);
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectChar(String ch) {
|
||||||
|
skipWhitespaceAndComments();
|
||||||
|
if (isAtEnd || _src[_pos] != ch) {
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected "$ch" at position $_pos, got '
|
||||||
|
'"${isAtEnd ? "EOF" : _src[_pos]}"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectWord(String word) {
|
||||||
|
skipWhitespaceAndComments();
|
||||||
|
final got = readWord();
|
||||||
|
if (got.toLowerCase() != word.toLowerCase()) {
|
||||||
|
throw SieveParseException(
|
||||||
|
'Expected "$word" at position $_pos, got "$got"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void skipToNextSemicolon() {
|
||||||
|
while (!isAtEnd && _src[_pos] != ';') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (!isAtEnd) _pos++; // skip ';'
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isWordChar(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return (c >= 0x41 && c <= 0x5A) || // A-Z
|
||||||
|
(c >= 0x61 && c <= 0x7A) || // a-z
|
||||||
|
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||||
|
c == 0x5F || // _
|
||||||
|
c == 0x2D; // -
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isDigit(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return c >= 0x30 && c <= 0x39;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
|
|
||||||
|
class SieveRule {
|
||||||
|
const SieveRule({
|
||||||
|
required this.joinType,
|
||||||
|
required this.conditions,
|
||||||
|
required this.actions,
|
||||||
|
this.branchGroupId,
|
||||||
|
this.isElseBranch = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'allof', 'anyof', or 'single'
|
||||||
|
final String joinType;
|
||||||
|
final List<SieveCondition> conditions;
|
||||||
|
final List<SieveAction> actions;
|
||||||
|
// Non-null groups this rule into an if/elsif/else chain.
|
||||||
|
final int? branchGroupId;
|
||||||
|
// True for the unconditional else branch.
|
||||||
|
final bool isElseBranch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
|
||||||
|
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
|
||||||
|
/// (RFC 5228 subset).
|
||||||
|
class SieveSerializer {
|
||||||
|
String serialize(FilterGroup filter, List<SieveAction> actions) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
final requires = _collectRequires(actions);
|
||||||
|
if (requires.isNotEmpty) {
|
||||||
|
buf.writeln(
|
||||||
|
'require [${requires.map((r) => '"$r"').join(', ')}];',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.isEmpty) {
|
||||||
|
for (final a in actions) {
|
||||||
|
buf.writeln(_serializeAction(a));
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
buf.write('if ');
|
||||||
|
buf.write(_serializeNode(filter));
|
||||||
|
buf.writeln(' {');
|
||||||
|
for (final a in actions) {
|
||||||
|
buf.writeln(' ${_serializeAction(a)}');
|
||||||
|
}
|
||||||
|
buf.writeln('}');
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _collectRequires(List<SieveAction> actions) {
|
||||||
|
final req = <String>[];
|
||||||
|
for (final a in actions) {
|
||||||
|
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
|
||||||
|
if ((a is FlagAction || a is MarkAsSeenAction) &&
|
||||||
|
!req.contains('imap4flags')) {
|
||||||
|
req.add('imap4flags');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeNode(FilterNode node) => switch (node) {
|
||||||
|
final FilterLeaf leaf => _serializeLeaf(leaf),
|
||||||
|
final FilterGroup group => _serializeGroup(group),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _serializeGroup(FilterGroup group) {
|
||||||
|
if (group.isEmpty) return 'true';
|
||||||
|
if (group.children.length == 1) return _serializeNode(group.children.first);
|
||||||
|
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
|
||||||
|
final parts = group.children.map(_serializeNode).join(',\n ');
|
||||||
|
return '$op(\n $parts\n)';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
|
||||||
|
FilterField.from_ ||
|
||||||
|
FilterField.to ||
|
||||||
|
FilterField.cc =>
|
||||||
|
_serializeAddressLeaf(leaf),
|
||||||
|
FilterField.subject => _serializeHeaderLeaf(leaf),
|
||||||
|
FilterField.size => _serializeSizeLeaf(leaf),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _serializeAddressLeaf(FilterLeaf leaf) {
|
||||||
|
final header = switch (leaf.field) {
|
||||||
|
FilterField.from_ => 'from',
|
||||||
|
FilterField.to => 'to',
|
||||||
|
FilterField.cc => 'cc',
|
||||||
|
_ => throw StateError('not an address field'),
|
||||||
|
};
|
||||||
|
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeHeaderLeaf(FilterLeaf leaf) =>
|
||||||
|
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
|
||||||
|
|
||||||
|
String _serializeSizeLeaf(FilterLeaf leaf) {
|
||||||
|
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
|
||||||
|
return 'size $comp ${leaf.value}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _matchType(FilterComparison comp) => switch (comp) {
|
||||||
|
FilterComparison.contains => ':contains',
|
||||||
|
FilterComparison.is_ => ':is',
|
||||||
|
FilterComparison.matches => ':matches',
|
||||||
|
_ => ':contains',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _serializeAction(SieveAction action) => switch (action) {
|
||||||
|
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
|
||||||
|
KeepAction() => 'keep;',
|
||||||
|
DiscardAction() => 'discard;',
|
||||||
|
MarkAsSeenAction() => r'setflag "\\Seen";',
|
||||||
|
final FlagAction a =>
|
||||||
|
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -201,6 +202,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
bool _running = false;
|
bool _running = false;
|
||||||
int _backoffSeconds = 5;
|
int _backoffSeconds = 5;
|
||||||
Completer<void>? _stopSignal;
|
Completer<void>? _stopSignal;
|
||||||
|
Timer? _waitTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void start() {
|
void start() {
|
||||||
@@ -258,6 +260,8 @@ class _AccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
|
stackTrace: st.toString(),
|
||||||
|
isPermanent: isPermanent,
|
||||||
protocol: 'imap',
|
protocol: 'imap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
@@ -293,6 +297,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
if (isTlsConfigError(e)) return true;
|
if (isTlsConfigError(e)) return true;
|
||||||
|
if (e is MissingPluginException) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
@@ -303,11 +308,16 @@ class _AccountSync implements _SyncLoop {
|
|||||||
Future<void> _waitSeconds(int seconds) async {
|
Future<void> _waitSeconds(int seconds) async {
|
||||||
if (!_running) return;
|
if (!_running) return;
|
||||||
_stopSignal = Completer<void>();
|
_stopSignal = Completer<void>();
|
||||||
await Future.any([
|
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||||
Future.delayed(Duration(seconds: seconds)),
|
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
});
|
||||||
]);
|
try {
|
||||||
_stopSignal = null;
|
await _stopSignal!.future;
|
||||||
|
} finally {
|
||||||
|
_waitTimer?.cancel();
|
||||||
|
_waitTimer = null;
|
||||||
|
_stopSignal = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||||
@@ -341,6 +351,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
final mailboxStats = <MailboxSyncStats>[];
|
final mailboxStats = <MailboxSyncStats>[];
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!_running) break;
|
||||||
|
final mailboxStart = DateTime.now();
|
||||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||||
emailResult += r;
|
emailResult += r;
|
||||||
mailboxStats.add(
|
mailboxStats.add(
|
||||||
@@ -349,9 +360,11 @@ class _AccountSync implements _SyncLoop {
|
|||||||
fetched: r.fetched,
|
fetched: r.fetched,
|
||||||
skipped: r.skipped,
|
skipped: r.skipped,
|
||||||
bytesTransferred: r.bytesTransferred,
|
bytesTransferred: r.bytesTransferred,
|
||||||
|
duration: DateTime.now().difference(mailboxStart),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await _emails.applySieveRules(account.id);
|
||||||
return _SyncStats(
|
return _SyncStats(
|
||||||
emailsFetched: emailResult.fetched,
|
emailsFetched: emailResult.fetched,
|
||||||
emailsSkipped: emailResult.skipped,
|
emailsSkipped: emailResult.skipped,
|
||||||
@@ -394,11 +407,16 @@ class _AccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
// Cap IDLE at 25 minutes (RFC 2177). Also wakes up when stop() is
|
// Cap IDLE at 25 minutes (RFC 2177). Also wakes up when stop() is
|
||||||
// called or a new message / expunge event arrives.
|
// called or a new message / expunge event arrives.
|
||||||
await Future.any([
|
final idleTimer = Timer(const Duration(minutes: 25), () {
|
||||||
newMessageCompleter.future,
|
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||||
Future.delayed(const Duration(minutes: 25)),
|
_stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
}
|
||||||
]);
|
});
|
||||||
|
try {
|
||||||
|
await Future.any([newMessageCompleter.future, _stopSignal!.future]);
|
||||||
|
} finally {
|
||||||
|
idleTimer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
await client.idleDone();
|
await client.idleDone();
|
||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
@@ -439,6 +457,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
bool _running = false;
|
bool _running = false;
|
||||||
int _backoffSeconds = 5;
|
int _backoffSeconds = 5;
|
||||||
Completer<void>? _stopSignal;
|
Completer<void>? _stopSignal;
|
||||||
|
Timer? _waitTimer;
|
||||||
|
|
||||||
static const _pollInterval = Duration(seconds: 30);
|
static const _pollInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
@@ -496,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: e.toString(),
|
errorMessage: e.toString(),
|
||||||
|
stackTrace: st.toString(),
|
||||||
|
isPermanent: isPermanent,
|
||||||
protocol: 'jmap',
|
protocol: 'jmap',
|
||||||
emailsFetched: 0,
|
emailsFetched: 0,
|
||||||
emailsSkipped: 0,
|
emailsSkipped: 0,
|
||||||
@@ -531,6 +552,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
if (isTlsConfigError(e)) return true;
|
if (isTlsConfigError(e)) return true;
|
||||||
|
if (e is MissingPluginException) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
s.contains('authentication failed') ||
|
s.contains('authentication failed') ||
|
||||||
@@ -542,11 +564,16 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
Future<void> _waitSeconds(int seconds) async {
|
Future<void> _waitSeconds(int seconds) async {
|
||||||
if (!_running) return;
|
if (!_running) return;
|
||||||
_stopSignal = Completer<void>();
|
_stopSignal = Completer<void>();
|
||||||
await Future.any([
|
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||||
Future.delayed(Duration(seconds: seconds)),
|
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
});
|
||||||
]);
|
try {
|
||||||
_stopSignal = null;
|
await _stopSignal!.future;
|
||||||
|
} finally {
|
||||||
|
_waitTimer?.cancel();
|
||||||
|
_waitTimer = null;
|
||||||
|
_stopSignal = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||||
@@ -581,6 +608,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
final mailboxStats = <MailboxSyncStats>[];
|
final mailboxStats = <MailboxSyncStats>[];
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!_running) break;
|
||||||
|
final mailboxStart = DateTime.now();
|
||||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||||
emailResult += r;
|
emailResult += r;
|
||||||
mailboxStats.add(
|
mailboxStats.add(
|
||||||
@@ -589,9 +617,11 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
fetched: r.fetched,
|
fetched: r.fetched,
|
||||||
skipped: r.skipped,
|
skipped: r.skipped,
|
||||||
bytesTransferred: r.bytesTransferred,
|
bytesTransferred: r.bytesTransferred,
|
||||||
|
duration: DateTime.now().difference(mailboxStart),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await _emails.applySieveRules(account.id);
|
||||||
return _SyncStats(
|
return _SyncStats(
|
||||||
emailsFetched: emailResult.fetched,
|
emailsFetched: emailResult.fetched,
|
||||||
emailsSkipped: emailResult.skipped,
|
emailsSkipped: emailResult.skipped,
|
||||||
@@ -618,11 +648,16 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
onError: (_) {},
|
onError: (_) {},
|
||||||
);
|
);
|
||||||
|
|
||||||
await Future.any([
|
final pollTimer = Timer(_pollInterval, () {
|
||||||
pushReady.future,
|
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||||
Future.delayed(_pollInterval),
|
_stopSignal!.complete();
|
||||||
_stopSignal!.future,
|
}
|
||||||
]);
|
});
|
||||||
|
try {
|
||||||
|
await Future.any([pushReady.future, _stopSignal!.future]);
|
||||||
|
} finally {
|
||||||
|
pollTimer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
await pushSub.cancel();
|
await pushSub.cancel();
|
||||||
_stopSignal = null;
|
_stopSignal = null;
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import 'dart:io';
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/body_cache_service.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
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';
|
||||||
@@ -19,27 +23,68 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
|||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
const _kTaskName = 'si_bg_sync';
|
const _kTaskName = 'si_bg_sync';
|
||||||
|
const _kPrefetchTaskName = 'si_bg_prefetch';
|
||||||
const _kResourceType = 'background_check';
|
const _kResourceType = 'background_check';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void callbackDispatcher() {
|
void callbackDispatcher() {
|
||||||
Workmanager().executeTask((_, __) async {
|
// Required so that path_provider and other plugins are available in this
|
||||||
|
// background isolate (issue #192).
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
Workmanager().executeTask((taskName, __) async {
|
||||||
try {
|
try {
|
||||||
await _doBackgroundSync();
|
if (taskName == _kPrefetchTaskName) {
|
||||||
|
await _doBodyPrefetch();
|
||||||
|
} else {
|
||||||
|
await _doBackgroundSync();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerBackgroundSync() async {
|
Future<void> registerBackgroundSync() async {
|
||||||
await Workmanager().initialize(callbackDispatcher);
|
try {
|
||||||
await Workmanager().registerPeriodicTask(
|
await Workmanager().initialize(callbackDispatcher);
|
||||||
_kTaskName,
|
await Workmanager().registerPeriodicTask(
|
||||||
_kTaskName,
|
_kTaskName,
|
||||||
frequency: const Duration(minutes: 15),
|
_kTaskName,
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
frequency: const Duration(minutes: 15),
|
||||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
);
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// WorkManager channel unavailable on this device; background sync disabled.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; background sync disabled.
|
||||||
|
} catch (_) {
|
||||||
|
// Unexpected initialization failure; background sync disabled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
|
||||||
|
/// Call on app startup and whenever the user changes the prefetch preference.
|
||||||
|
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
|
||||||
|
try {
|
||||||
|
if (mode == PrefetchMode.disabled) {
|
||||||
|
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final networkType = mode == PrefetchMode.wifiOnly
|
||||||
|
? NetworkType.unmetered
|
||||||
|
: NetworkType.connected;
|
||||||
|
await Workmanager().registerPeriodicTask(
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
frequency: const Duration(hours: 1),
|
||||||
|
constraints: Constraints(networkType: networkType),
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// Ignore — WorkManager unavailable.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Ignore — plugin not registered.
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doBackgroundSync() async {
|
Future<void> _doBackgroundSync() async {
|
||||||
@@ -63,6 +108,22 @@ Future<void> _doBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _doBodyPrefetch() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final db = AppDatabase(
|
||||||
|
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final accountRepo = AccountRepositoryImpl(
|
||||||
|
db,
|
||||||
|
const FlutterSecureStorageImpl(),
|
||||||
|
);
|
||||||
|
await BodyCacheService(db, accountRepo).run();
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkAccount(
|
Future<void> _checkAccount(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountRepository accountRepo,
|
AccountRepository accountRepo,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class ReliabilityRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runForAccount(String accountId) async {
|
Future<void> _runForAccount(String accountId, {bool force = false}) async {
|
||||||
try {
|
try {
|
||||||
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
||||||
var totalMissingLocally = 0;
|
var totalMissingLocally = 0;
|
||||||
@@ -59,7 +59,7 @@ class ReliabilityRunner {
|
|||||||
final details = <String, dynamic>{};
|
final details = <String, dynamic>{};
|
||||||
|
|
||||||
for (final mailbox in mailboxes) {
|
for (final mailbox in mailboxes) {
|
||||||
if (!_running) break;
|
if (!force && !_running) break;
|
||||||
final result = await _emails.verifySyncReliability(
|
final result = await _emails.verifySyncReliability(
|
||||||
accountId,
|
accountId,
|
||||||
mailbox.path,
|
mailbox.path,
|
||||||
@@ -103,7 +103,14 @@ class ReliabilityRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forces a reliability check for all accounts immediately.
|
/// Forces a reliability check for all accounts immediately.
|
||||||
|
///
|
||||||
|
/// Works regardless of whether [start] has been called, so the UI can
|
||||||
|
/// trigger a manual check at any time without depending on the periodic
|
||||||
|
/// runner being active.
|
||||||
Future<void> checkNow() async {
|
Future<void> checkNow() async {
|
||||||
await _runAll();
|
final accounts = await _accounts.observeAccounts().first;
|
||||||
|
for (final account in accounts) {
|
||||||
|
await _runForAccount(account.id, force: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
|
||||||
|
/// Replaces `src="cid:..."` references in [html] with inline `data:` URIs
|
||||||
|
/// by looking up each Content-ID in the MIME tree of [msg].
|
||||||
|
///
|
||||||
|
/// Emails with `multipart/related` often embed images this way. Without
|
||||||
|
/// substitution the WebView shows broken image icons even after the full
|
||||||
|
/// message has been downloaded.
|
||||||
|
String injectInlineImages(String html, imap.MimeMessage msg) {
|
||||||
|
final inlineParts = msg.findContentInfo(
|
||||||
|
disposition: imap.ContentDisposition.inline,
|
||||||
|
);
|
||||||
|
if (inlineParts.isEmpty) return html;
|
||||||
|
|
||||||
|
var result = html;
|
||||||
|
for (final info in inlineParts) {
|
||||||
|
final cid = info.cid;
|
||||||
|
if (cid == null || cid.isEmpty) continue;
|
||||||
|
final bareCid = cid.startsWith('<') && cid.endsWith('>')
|
||||||
|
? cid.substring(1, cid.length - 1)
|
||||||
|
: cid;
|
||||||
|
|
||||||
|
final part = msg.getPart(info.fetchId);
|
||||||
|
if (part == null) continue;
|
||||||
|
final bytes = part.decodeContentBinary();
|
||||||
|
if (bytes == null || bytes.isEmpty) continue;
|
||||||
|
|
||||||
|
final contentType =
|
||||||
|
info.contentType?.mediaType.text ?? 'application/octet-stream';
|
||||||
|
final dataUri = 'data:$contentType;base64,${base64.encode(bytes)}';
|
||||||
|
|
||||||
|
result = result
|
||||||
|
.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'");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// Returns true if [value] matches the glob [pattern].
|
||||||
|
///
|
||||||
|
/// Supports `*` (any number of characters) and `?` (exactly one character).
|
||||||
|
/// The comparison is case-insensitive, which is appropriate for email addresses.
|
||||||
|
bool globMatch(String value, String pattern) {
|
||||||
|
final regexStr =
|
||||||
|
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||||
|
return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -107,6 +110,8 @@ class EmailBodies extends Table {
|
|||||||
DateTimeColumn get cachedAt => dateTime().nullable()();
|
DateTimeColumn get cachedAt => dateTime().nullable()();
|
||||||
// Added in schema v20: raw or parsed headers
|
// Added in schema v20: raw or parsed headers
|
||||||
TextColumn get headersJson => text().nullable()();
|
TextColumn get headersJson => text().nullable()();
|
||||||
|
// Added in schema v28: serialised MimePart tree (JSON)
|
||||||
|
TextColumn get mimeTreeJson => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {emailId};
|
Set<Column> get primaryKey => {emailId};
|
||||||
@@ -189,6 +194,9 @@ class SyncLogs extends Table {
|
|||||||
DateTimeColumn get finishedAt => dateTime()();
|
DateTimeColumn get finishedAt => dateTime()();
|
||||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||||
TextColumn get protocolLog => text().nullable()();
|
TextColumn get protocolLog => text().nullable()();
|
||||||
|
// Added in schema v33: stack trace and permanent flag for error entries.
|
||||||
|
TextColumn get errorStackTrace => text().nullable()();
|
||||||
|
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-mailbox breakdown for a single sync cycle.
|
/// Per-mailbox breakdown for a single sync cycle.
|
||||||
@@ -202,6 +210,8 @@ class SyncLogMailboxes extends Table {
|
|||||||
IntColumn get fetched => integer().withDefault(const Constant(0))();
|
IntColumn get fetched => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get skipped => integer().withDefault(const Constant(0))();
|
IntColumn get skipped => integer().withDefault(const Constant(0))();
|
||||||
IntColumn get bytesTransferred => integer().withDefault(const Constant(0))();
|
IntColumn get bytesTransferred => integer().withDefault(const Constant(0))();
|
||||||
|
// Added in schema v30: how long this mailbox took to sync, in milliseconds.
|
||||||
|
IntColumn get durationMs => integer().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores the result of the periodic "ground truth" verification.
|
/// Stores the result of the periodic "ground truth" verification.
|
||||||
@@ -234,6 +244,25 @@ class Drafts extends Table {
|
|||||||
TextColumn get imapServerId => text().nullable()();
|
TextColumn get imapServerId => text().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ephemeral public/private key pair generated for secure account sharing.
|
||||||
|
/// Expires after 20 minutes; used to decrypt an incoming encrypted-accounts QR.
|
||||||
|
@DataClassName('ShareKeyRow')
|
||||||
|
class ShareKeys extends Table {
|
||||||
|
/// Random 16-byte key ID, hex-encoded. Identifies which key pair the sender
|
||||||
|
/// used so the receiver can look it up even if multiple pairs exist.
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
/// Base64-encoded X25519 public key (32 bytes).
|
||||||
|
TextColumn get publicKey => text()();
|
||||||
|
|
||||||
|
/// Base64-encoded X25519 private key (32 bytes).
|
||||||
|
TextColumn get privateKey => text()();
|
||||||
|
DateTimeColumn get expiresAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
@DataClassName('SearchHistoryRow')
|
@DataClassName('SearchHistoryRow')
|
||||||
class SearchHistoryEntries extends Table {
|
class SearchHistoryEntries extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
@@ -241,6 +270,16 @@ class SearchHistoryEntries extends Table {
|
|||||||
DateTimeColumn get searchedAt => dateTime()();
|
DateTimeColumn get searchedAt => dateTime()();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DataClassName('LocalSieveScriptRow')
|
||||||
|
class LocalSieveScripts extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
TextColumn get name => text()();
|
||||||
|
TextColumn get content => text().withDefault(const Constant(''))();
|
||||||
|
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
|
||||||
|
}
|
||||||
|
|
||||||
@DataClassName('UndoActionRow')
|
@DataClassName('UndoActionRow')
|
||||||
class UndoActions extends Table {
|
class UndoActions extends Table {
|
||||||
TextColumn get id => text()();
|
TextColumn get id => text()();
|
||||||
@@ -254,6 +293,86 @@ class UndoActions extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records which emails have already had local Sieve rules applied.
|
||||||
|
/// Keyed by (accountId, messageId) so the same email is never processed twice,
|
||||||
|
/// even across restarts or re-syncs.
|
||||||
|
@DataClassName('LocalSieveAppliedRow')
|
||||||
|
class LocalSieveApplied extends Table {
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
// RFC 2822 Message-ID header value — stable across folder moves.
|
||||||
|
TextColumn get messageId => text()();
|
||||||
|
DateTimeColumn get appliedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {accountId, messageId};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Senders for whom remote images are loaded automatically.
|
||||||
|
/// Per-device/per-user — not tied to any email account.
|
||||||
|
@DataClassName('ImageTrustedSenderRow')
|
||||||
|
class ImageTrustedSenders extends Table {
|
||||||
|
TextColumn get senderEmail => text()();
|
||||||
|
DateTimeColumn get addedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {senderEmail};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
|
||||||
|
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
|
||||||
|
// Added in schema v39.
|
||||||
|
@DataClassName('EmailNoteRow')
|
||||||
|
class EmailNotes extends Table {
|
||||||
|
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
|
||||||
|
TextColumn get id => text()();
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
|
||||||
|
TextColumn get messageId => text()();
|
||||||
|
TextColumn get noteText => text()();
|
||||||
|
// IMAP UID (as string) or JMAP email ID of the note message on the server.
|
||||||
|
TextColumn get serverId => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the first time the user ran each app version (identified by GIT_HASH).
|
||||||
|
/// Added in schema v40.
|
||||||
|
@DataClassName('InstalledVersionRow')
|
||||||
|
class InstalledVersions extends Table {
|
||||||
|
TextColumn get gitHash => text()();
|
||||||
|
DateTimeColumn get installedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {gitHash};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
|
@DataClassName('UserPreferencesRow')
|
||||||
|
class UserPreferences extends Table {
|
||||||
|
IntColumn get id => integer()();
|
||||||
|
// 'bottom' (default) | 'top'
|
||||||
|
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
|
||||||
|
// Added in schema v35: 'bottom' (default) | 'top'
|
||||||
|
TextColumn get mailViewButtonPosition =>
|
||||||
|
text().withDefault(const Constant('bottom'))();
|
||||||
|
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||||
|
TextColumn get afterMailViewAction =>
|
||||||
|
text().withDefault(const Constant('nextMessage'))();
|
||||||
|
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
|
||||||
|
TextColumn get prefetchMode =>
|
||||||
|
text().withDefault(const Constant('wifiOnly'))();
|
||||||
|
// Added in schema v38: max cache size for offline email bodies, in megabytes.
|
||||||
|
IntColumn get bodyCacheLimitMb =>
|
||||||
|
integer().withDefault(const Constant(100))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────────
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
@@ -271,13 +390,20 @@ class UndoActions extends Table {
|
|||||||
SyncHealth,
|
SyncHealth,
|
||||||
UndoActions,
|
UndoActions,
|
||||||
SearchHistoryEntries,
|
SearchHistoryEntries,
|
||||||
|
LocalSieveScripts,
|
||||||
|
LocalSieveApplied,
|
||||||
|
ShareKeys,
|
||||||
|
UserPreferences,
|
||||||
|
ImageTrustedSenders,
|
||||||
|
EmailNotes,
|
||||||
|
InstalledVersions,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 27;
|
int get schemaVersion => dbSchemaVersion;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
@@ -503,8 +629,187 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 27) {
|
if (from < 27) {
|
||||||
await m.createTable(searchHistoryEntries);
|
await m.createTable(searchHistoryEntries);
|
||||||
}
|
}
|
||||||
|
if (from < 28) {
|
||||||
|
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
|
||||||
|
}
|
||||||
|
if (from < 29) {
|
||||||
|
await m.createTable(localSieveScripts);
|
||||||
|
}
|
||||||
|
if (from >= 12 && from < 30) {
|
||||||
|
await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs);
|
||||||
|
}
|
||||||
|
if (from < 31) {
|
||||||
|
await m.createTable(shareKeys);
|
||||||
|
}
|
||||||
|
if (from < 32) {
|
||||||
|
await m.createTable(localSieveApplied);
|
||||||
|
}
|
||||||
|
if (from >= 7 && from < 33) {
|
||||||
|
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||||
|
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||||
|
}
|
||||||
|
if (from < 34) {
|
||||||
|
await m.createTable(userPreferences);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 35) {
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.mailViewButtonPosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 36) {
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.afterMailViewAction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 37) {
|
||||||
|
await m.createTable(imageTrustedSenders);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 38) {
|
||||||
|
await m.addColumn(userPreferences, userPreferences.prefetchMode);
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.bodyCacheLimitMb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 39) {
|
||||||
|
await m.createTable(emailNotes);
|
||||||
|
}
|
||||||
|
if (from < 40) {
|
||||||
|
await m.createTable(installedVersions);
|
||||||
|
}
|
||||||
|
if (from < 41) {
|
||||||
|
// Fix IMAP email IDs to include mailboxPath, preventing UID
|
||||||
|
// collisions across mailboxes (IMAP UIDs are mailbox-scoped).
|
||||||
|
// New format: "accountId:mailboxPath:uid" (was "accountId:uid").
|
||||||
|
//
|
||||||
|
// defer_foreign_keys defers the email_bodies→emails FK check
|
||||||
|
// to COMMIT so the two tables can be updated sequentially inside
|
||||||
|
// the migration transaction without a transient FK violation.
|
||||||
|
await customStatement('PRAGMA defer_foreign_keys = ON');
|
||||||
|
|
||||||
|
// 1. Remap email_bodies.email_id before emails.id changes.
|
||||||
|
await customStatement('''
|
||||||
|
UPDATE email_bodies
|
||||||
|
SET email_id = (
|
||||||
|
SELECT e.account_id || ':' || e.mailbox_path || ':' || CAST(e.uid AS TEXT)
|
||||||
|
FROM emails e
|
||||||
|
JOIN accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.id = email_bodies.email_id
|
||||||
|
AND a.account_type = 'imap'
|
||||||
|
)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM emails e
|
||||||
|
JOIN accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.id = email_bodies.email_id
|
||||||
|
AND a.account_type = 'imap'
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 2. Update emails.thread_id where it was set to the email's own
|
||||||
|
// id (fallback for messages with no Message-ID header).
|
||||||
|
await customStatement('''
|
||||||
|
UPDATE emails
|
||||||
|
SET thread_id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
|
||||||
|
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
|
||||||
|
AND thread_id = id
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 3. Update the primary key on emails.
|
||||||
|
await customStatement('''
|
||||||
|
UPDATE emails
|
||||||
|
SET id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
|
||||||
|
WHERE account_id IN (
|
||||||
|
SELECT id FROM accounts WHERE account_type = 'imap'
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 5. Rebuild threads for IMAP accounts from the updated email rows.
|
||||||
|
// The threads table stores denormalised data (latest_email_id,
|
||||||
|
// email_ids_json) that references email IDs, so it is simpler to
|
||||||
|
// delete and reconstruct than to patch the JSON in SQL.
|
||||||
|
await customStatement('''
|
||||||
|
DELETE FROM threads
|
||||||
|
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
|
||||||
|
''');
|
||||||
|
|
||||||
|
final imapAccounts = await (select(accounts)
|
||||||
|
..where((t) => t.accountType.equals('imap')))
|
||||||
|
.get();
|
||||||
|
for (final acct in imapAccounts) {
|
||||||
|
final emailRows = await (select(emails)
|
||||||
|
..where((t) => t.accountId.equals(acct.id)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final groups = <String, List<Email>>{};
|
||||||
|
for (final row in emailRows) {
|
||||||
|
final key = '${row.mailboxPath}:${row.threadId ?? row.id}';
|
||||||
|
groups.putIfAbsent(key, () => []).add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final threadEmails in groups.values) {
|
||||||
|
threadEmails.sort((a, b) {
|
||||||
|
final da = a.sentAt ?? a.receivedAt;
|
||||||
|
final db = b.sentAt ?? b.receivedAt;
|
||||||
|
return da.compareTo(db);
|
||||||
|
});
|
||||||
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final participants = <Map<String, dynamic>>[];
|
||||||
|
for (final e in threadEmails) {
|
||||||
|
final from = jsonDecode(e.fromJson) as List<dynamic>;
|
||||||
|
for (final a in from.cast<Map<String, dynamic>>()) {
|
||||||
|
final email = a['email'] as String;
|
||||||
|
if (seen.add(email)) {
|
||||||
|
participants.add({'name': a['name'], 'email': email});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await into(threads).insert(
|
||||||
|
ThreadsCompanion.insert(
|
||||||
|
id: latest.threadId ?? latest.id,
|
||||||
|
accountId: latest.accountId,
|
||||||
|
mailboxPath: latest.mailboxPath,
|
||||||
|
subject: Value(latest.subject),
|
||||||
|
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||||
|
messageCount: Value(threadEmails.length),
|
||||||
|
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||||
|
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||||
|
participantsJson: Value(jsonEncode(participants)),
|
||||||
|
preview: Value(latest.preview),
|
||||||
|
latestEmailId: latest.id,
|
||||||
|
emailIdsJson: Value(
|
||||||
|
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Inserts a row for [gitHash] the first time that version is seen.
|
||||||
|
/// Subsequent calls for the same hash are silently ignored so the original
|
||||||
|
/// install timestamp is preserved.
|
||||||
|
Future<void> recordInstalledVersionIfNew(String gitHash) async {
|
||||||
|
if (gitHash.isEmpty) return;
|
||||||
|
await into(installedVersions).insert(
|
||||||
|
InstalledVersionsCompanion.insert(
|
||||||
|
gitHash: gitHash,
|
||||||
|
installedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
mode: InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, DateTime>> loadInstalledVersions() async {
|
||||||
|
final rows = await select(installedVersions).get();
|
||||||
|
return {for (final r in rows) r.gitHash: r.installedAt};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved once in main() via initDatabasePath() before runApp().
|
// Resolved once in main() via initDatabasePath() before runApp().
|
||||||
@@ -512,29 +817,121 @@ String? _dbPath;
|
|||||||
|
|
||||||
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
||||||
/// path_provider plugin channel is registered before the first DB access.
|
/// path_provider plugin channel is registered before the first DB access.
|
||||||
|
/// On some Android versions the Pigeon channel is not ready at the very
|
||||||
|
/// start of main(); if it fails, _openConnection() retries lazily.
|
||||||
Future<void> initDatabasePath() async {
|
Future<void> initDatabasePath() async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
try {
|
||||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||||
|
} on PlatformException {
|
||||||
|
// Channel not yet established; LazyDatabase will resolve the path
|
||||||
|
// on first access, after runApp() completes initialization.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the application support path, retrying on PlatformException to
|
||||||
|
/// survive a race where the path_provider Pigeon channel isn't ready yet.
|
||||||
|
Future<String> _resolveDatabasePath() async {
|
||||||
|
if (_dbPath != null) return _dbPath!;
|
||||||
|
// initDatabasePath() failed (channel not ready before runApp). Retry now
|
||||||
|
// that the engine is fully initialised, with back-off. Some slow Android
|
||||||
|
// devices need several seconds for the Pigeon channel to become ready
|
||||||
|
// (issue #166), so use a longer schedule than the initial attempt.
|
||||||
|
const delays = [200, 500, 1000, 2000, 4000];
|
||||||
|
for (final ms in delays) {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||||
|
return _dbPath!;
|
||||||
|
} on PlatformException {
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On Android, path_provider can be permanently broken on some devices
|
||||||
|
// regardless of how long we wait (issue #192). Derive the path from
|
||||||
|
// /proc/self/cmdline (the Android process name == package name) without
|
||||||
|
// a platform channel as a last resort so the app can still open its DB.
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final fallback = await _androidFallbackPath();
|
||||||
|
if (fallback != null) {
|
||||||
|
_dbPath = fallback;
|
||||||
|
return _dbPath!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||||
|
'cannot open database.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads /proc/self/cmdline to extract the Android package name, then
|
||||||
|
// constructs the standard app files-dir path without a platform channel.
|
||||||
|
// Returns null when the path cannot be determined or created.
|
||||||
|
Future<String?> _androidFallbackPath() async {
|
||||||
|
try {
|
||||||
|
final bytes = await File('/proc/self/cmdline').readAsBytes();
|
||||||
|
final end = bytes.indexOf(0);
|
||||||
|
final packageName = String.fromCharCodes(
|
||||||
|
end >= 0 ? bytes.sublist(0, end) : bytes,
|
||||||
|
).trim();
|
||||||
|
// A valid Android package name contains dots but not slashes.
|
||||||
|
if (packageName.isEmpty ||
|
||||||
|
!packageName.contains('.') ||
|
||||||
|
packageName.contains('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final base in [
|
||||||
|
'/data/user/0/$packageName/files',
|
||||||
|
'/data/data/$packageName/files',
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await Directory(base).create(recursive: true);
|
||||||
|
return p.join(base, 'sharedinbox.db');
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These functions are only called from unit tests (database_path_test.dart).
|
||||||
|
// They expose internals that cannot be reached via the public API.
|
||||||
|
Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||||
|
void resetDatabasePathForTesting() => _dbPath = null;
|
||||||
|
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||||
|
|
||||||
|
/// Configures PRAGMAs on a newly opened SQLite connection.
|
||||||
|
///
|
||||||
|
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
|
||||||
|
/// instead of immediately failing.
|
||||||
|
///
|
||||||
|
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
|
||||||
|
/// WorkManager background task may already have the DB open when the app
|
||||||
|
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
|
||||||
|
/// returned in that situation; it only occurs when the DB is already in WAL
|
||||||
|
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
|
||||||
|
void _setupPragmas(Database db) {
|
||||||
|
db.execute('PRAGMA busy_timeout = 5000;');
|
||||||
|
try {
|
||||||
|
db.execute('PRAGMA journal_mode = WAL;');
|
||||||
|
} on SqliteException catch (e) {
|
||||||
|
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
|
||||||
|
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
|
||||||
|
if (e.resultCode != 5) rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final file = File(
|
final file = File(await _resolveDatabasePath());
|
||||||
_dbPath ??
|
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||||
p.join(
|
|
||||||
(await getApplicationSupportDirectory()).path,
|
|
||||||
'sharedinbox.db',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return NativeDatabase.createInBackground(
|
|
||||||
file,
|
|
||||||
setup: (db) {
|
|
||||||
// WAL lets readers and writers proceed concurrently (different account
|
|
||||||
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
|
||||||
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
|
||||||
db.execute('PRAGMA journal_mode = WAL;');
|
|
||||||
db.execute('PRAGMA busy_timeout = 5000;');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposed so tests can run the exact production setup logic on a raw
|
||||||
|
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
|
||||||
|
void setupPragmasForTesting(Database db) => _setupPragmas(db);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
class LocalSieveRepository {
|
||||||
|
LocalSieveRepository(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||||
|
final rows = await (_db.select(
|
||||||
|
_db.localSieveScripts,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.get();
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(r) => SieveScript(
|
||||||
|
id: r.id.toString(),
|
||||||
|
name: r.name,
|
||||||
|
blobId: r.id.toString(),
|
||||||
|
isActive: r.isActive,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||||
|
final rowId = int.parse(blobId);
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.localSieveScripts,
|
||||||
|
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (row == null) throw Exception('Local script not found: $blobId');
|
||||||
|
return row.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SieveScript> saveScript(
|
||||||
|
String accountId, {
|
||||||
|
String? id,
|
||||||
|
required String name,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
if (id != null) {
|
||||||
|
final rowId = int.parse(id);
|
||||||
|
await (_db.update(_db.localSieveScripts)
|
||||||
|
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
|
.write(
|
||||||
|
LocalSieveScriptsCompanion(
|
||||||
|
name: Value(name),
|
||||||
|
content: Value(content),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final updated = await (_db.select(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return SieveScript(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
blobId: id,
|
||||||
|
isActive: updated?.isActive ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final rowId = await _db.into(_db.localSieveScripts).insert(
|
||||||
|
LocalSieveScriptsCompanion.insert(
|
||||||
|
accountId: accountId,
|
||||||
|
name: name,
|
||||||
|
content: Value(content),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final idStr = rowId.toString();
|
||||||
|
return SieveScript(id: idStr, name: name, blobId: idStr, isActive: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||||
|
final rowId = int.parse(scriptId);
|
||||||
|
await (_db.delete(
|
||||||
|
_db.localSieveScripts,
|
||||||
|
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> activateScript(String accountId, String scriptId) async {
|
||||||
|
await _db.transaction(() async {
|
||||||
|
await (_db.update(_db.localSieveScripts)
|
||||||
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||||
|
final rowId = int.parse(scriptId);
|
||||||
|
await (_db.update(_db.localSieveScripts)
|
||||||
|
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
|
|||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(
|
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
||||||
this._db,
|
: _imapConnect = imapConnect;
|
||||||
this._accounts, {
|
|
||||||
ImapConnectFn? imapConnect,
|
|
||||||
}) : _imapConnect = imapConnect;
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -124,10 +121,7 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWithServer(
|
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
||||||
imap.ImapClient client,
|
|
||||||
String accountId,
|
|
||||||
) async {
|
|
||||||
// Create/select the Drafts folder.
|
// Create/select the Drafts folder.
|
||||||
try {
|
try {
|
||||||
await client.createMailbox('Drafts');
|
await client.createMailbox('Drafts');
|
||||||
@@ -162,8 +156,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
? uidList.first.toString()
|
? uidList.first.toString()
|
||||||
: null;
|
: null;
|
||||||
if (uid != null) {
|
if (uid != null) {
|
||||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
||||||
.write(DraftsCompanion(imapServerId: Value(uid)));
|
DraftsCompanion(imapServerId: Value(uid)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_interpreter.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_parser.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/cid_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
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';
|
||||||
@@ -91,6 +96,26 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
|
||||||
|
final query = _db.select(_db.threads).join([
|
||||||
|
innerJoin(
|
||||||
|
_db.mailboxes,
|
||||||
|
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
|
||||||
|
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
query
|
||||||
|
..where(_db.mailboxes.role.equals('inbox'))
|
||||||
|
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
|
||||||
|
..limit(limit);
|
||||||
|
return query.watch().map(
|
||||||
|
(rows) => rows
|
||||||
|
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
model.EmailThread _threadRowToModel(ThreadRow row) {
|
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||||
List<model.EmailAddress> parseAddresses(String json) {
|
List<model.EmailAddress> parseAddresses(String json) {
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
@@ -152,6 +177,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (threadEmails.isEmpty) return;
|
||||||
final latest = threadEmails.last;
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
// Collect unique participants across the whole thread.
|
// Collect unique participants across the whole thread.
|
||||||
@@ -233,9 +259,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||||
final msg = fetch.messages.first;
|
final msg = fetch.messages.firstOrNull;
|
||||||
|
if (msg == null) {
|
||||||
|
throw StateError(
|
||||||
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final textBody = msg.decodeTextPlainPart();
|
final textBody = msg.decodeTextPlainPart();
|
||||||
final htmlBody = msg.decodeTextHtmlPart();
|
final rawHtml = msg.decodeTextHtmlPart();
|
||||||
|
final htmlBody =
|
||||||
|
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
|
||||||
final contentInfos = msg.findContentInfo();
|
final contentInfos = msg.findContentInfo();
|
||||||
|
|
||||||
final attachmentsJson = jsonEncode(
|
final attachmentsJson = jsonEncode(
|
||||||
@@ -259,6 +292,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final mimeTreeJson = _buildMimeTreeJson(msg);
|
||||||
|
|
||||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||||
EmailBodiesCompanion.insert(
|
EmailBodiesCompanion.insert(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
@@ -266,6 +301,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: Value(htmlBody),
|
htmlBody: Value(htmlBody),
|
||||||
attachmentsJson: Value(attachmentsJson),
|
attachmentsJson: Value(attachmentsJson),
|
||||||
headersJson: Value(headersJson),
|
headersJson: Value(headersJson),
|
||||||
|
mimeTreeJson: Value(mimeTreeJson),
|
||||||
cachedAt: Value(DateTime.now()),
|
cachedAt: Value(DateTime.now()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -275,6 +311,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: htmlBody,
|
htmlBody: htmlBody,
|
||||||
attachments: _parseAttachments(attachmentsJson),
|
attachments: _parseAttachments(attachmentsJson),
|
||||||
headers: _parseHeaders(headersJson),
|
headers: _parseHeaders(headersJson),
|
||||||
|
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
@@ -311,9 +348,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
'htmlBody',
|
'htmlBody',
|
||||||
'bodyValues',
|
'bodyValues',
|
||||||
'attachments',
|
'attachments',
|
||||||
|
'bodyStructure',
|
||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
|
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -333,6 +372,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final rawBodyStructure =
|
||||||
|
emailData['bodyStructure'] as Map<String, dynamic>?;
|
||||||
|
final mimeTreeJson = rawBodyStructure != null
|
||||||
|
? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure))
|
||||||
|
: null;
|
||||||
|
|
||||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||||
EmailBodiesCompanion.insert(
|
EmailBodiesCompanion.insert(
|
||||||
emailId: emailId,
|
emailId: emailId,
|
||||||
@@ -340,6 +385,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: Value(htmlBody),
|
htmlBody: Value(htmlBody),
|
||||||
attachmentsJson: Value(attachmentsJson),
|
attachmentsJson: Value(attachmentsJson),
|
||||||
headersJson: Value(headersJson),
|
headersJson: Value(headersJson),
|
||||||
|
mimeTreeJson: Value(mimeTreeJson),
|
||||||
cachedAt: Value(DateTime.now()),
|
cachedAt: Value(DateTime.now()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -350,6 +396,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: htmlBody,
|
htmlBody: htmlBody,
|
||||||
attachments: _parseAttachments(attachmentsJson),
|
attachments: _parseAttachments(attachmentsJson),
|
||||||
headers: _parseHeaders(headersJson),
|
headers: _parseHeaders(headersJson),
|
||||||
|
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
for (final msg in result.messages) {
|
for (final msg in result.messages) {
|
||||||
final uid = msg.uid;
|
final uid = msg.uid;
|
||||||
if (uid == null) continue;
|
if (uid == null) continue;
|
||||||
final emailId = '${account.id}:$uid';
|
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||||
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
||||||
EmailsCompanion(
|
EmailsCompanion(
|
||||||
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
||||||
@@ -569,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bytes += msg.size ?? 0;
|
bytes += msg.size ?? 0;
|
||||||
final emailId = '${account.id}:$uid';
|
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||||
final msgId = envelope.messageId?.trim();
|
final msgId = envelope.messageId?.trim();
|
||||||
final inReplyTo = envelope.inReplyTo?.trim();
|
final inReplyTo = envelope.inReplyTo?.trim();
|
||||||
final refs = msg.getHeaderValue('References')?.trim();
|
final refs = msg.getHeaderValue('References')?.trim();
|
||||||
@@ -1448,7 +1495,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
_db.emails,
|
_db.emails,
|
||||||
)..where((t) => t.id.equals(emailId)))
|
)..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingleOrNull();
|
||||||
|
if (row == null) return;
|
||||||
final account = (await _accounts.getAccount(row.accountId))!;
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
if (account.type == account_model.AccountType.jmap) {
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
@@ -1582,7 +1630,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
_db.emails,
|
_db.emails,
|
||||||
)..where((t) => t.id.equals(emailId)))
|
)..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingleOrNull();
|
||||||
|
if (row == null) return;
|
||||||
final account = (await _accounts.getAccount(row.accountId))!;
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
if (row.mailboxPath == destMailboxPath) {
|
if (row.mailboxPath == destMailboxPath) {
|
||||||
@@ -1650,7 +1699,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
_db.emails,
|
_db.emails,
|
||||||
)..where((t) => t.id.equals(emailId)))
|
)..where((t) => t.id.equals(emailId)))
|
||||||
.getSingle();
|
.getSingleOrNull();
|
||||||
|
if (row == null) return null;
|
||||||
final account = (await _accounts.getAccount(row.accountId))!;
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
// Move to Trash when possible so the user can recover the message.
|
// Move to Trash when possible so the user can recover the message.
|
||||||
@@ -1844,6 +1894,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return expired.length;
|
return expired.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@override
|
||||||
|
Future<model.Email?> findEmailByMessageId(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final row = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _toModel(row);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> restoreEmails(List<model.Email> emails) async {
|
Future<void> restoreEmails(List<model.Email> emails) async {
|
||||||
for (final e in emails) {
|
for (final e in emails) {
|
||||||
@@ -1875,6 +1941,221 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies locally stored active Sieve rules to INBOX emails that have not
|
||||||
|
/// been processed yet. See [EmailRepository.applySieveRules] for details.
|
||||||
|
@override
|
||||||
|
Future<int> applySieveRules(String accountId) async {
|
||||||
|
final scriptRow = await (_db.select(_db.localSieveScripts)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.isActive.equals(true),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (scriptRow == null) return 0;
|
||||||
|
|
||||||
|
List<SieveRule> rules;
|
||||||
|
try {
|
||||||
|
rules = SieveParser().parse(scriptRow.content);
|
||||||
|
} catch (e) {
|
||||||
|
log('Sieve parse error for account $accountId: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (rules.isEmpty) return 0;
|
||||||
|
|
||||||
|
final inboxMailbox = await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
|
final alreadyApplied = await (_db.select(
|
||||||
|
_db.localSieveApplied,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
|
.get();
|
||||||
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
|
final inboxEmails = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(inboxPath) &
|
||||||
|
t.messageId.isNotNull(),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final interpreter = SieveInterpreter();
|
||||||
|
var matched = 0;
|
||||||
|
|
||||||
|
for (final row in inboxEmails) {
|
||||||
|
final msgId = row.messageId!;
|
||||||
|
if (appliedIds.contains(msgId)) continue;
|
||||||
|
|
||||||
|
final emailCtx = _buildSieveContext(row);
|
||||||
|
|
||||||
|
SieveExecutionContext result;
|
||||||
|
try {
|
||||||
|
result = interpreter.execute(rules, emailCtx);
|
||||||
|
} catch (e) {
|
||||||
|
log('Sieve interpreter error for message $msgId: $e');
|
||||||
|
await _markSieveApplied(accountId, msgId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _markSieveApplied(accountId, msgId);
|
||||||
|
|
||||||
|
if (result.isCancelled) {
|
||||||
|
await _enqueueSieveDelete(account, row);
|
||||||
|
matched++;
|
||||||
|
} else if (result.targetFolders.isNotEmpty) {
|
||||||
|
final dest = result.targetFolders.first;
|
||||||
|
await _enqueueSieveMove(account, row, dest);
|
||||||
|
matched++;
|
||||||
|
} else if (result.flagsToAdd.isNotEmpty) {
|
||||||
|
await _enqueueSieveFlagSeen(account, row);
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
SieveEmailContext _buildSieveContext(Email row) {
|
||||||
|
String formatAddrs(String json) {
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
|
return list.map((e) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
final name = m['name'] as String? ?? '';
|
||||||
|
final email = m['email'] as String? ?? '';
|
||||||
|
return name.isEmpty ? email : '$name <$email>';
|
||||||
|
}).join(', ');
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SieveEmailContext(
|
||||||
|
headers: {
|
||||||
|
if (row.subject != null && row.subject!.isNotEmpty)
|
||||||
|
'subject': [row.subject!],
|
||||||
|
'from': [formatAddrs(row.fromJson)],
|
||||||
|
'to': [formatAddrs(row.toAddresses)],
|
||||||
|
'cc': [formatAddrs(row.ccJson)],
|
||||||
|
if (row.messageId != null) 'message-id': [row.messageId!],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markSieveApplied(String accountId, String messageId) async {
|
||||||
|
await _db.into(_db.localSieveApplied).insertOnConflictUpdate(
|
||||||
|
LocalSieveAppliedCompanion.insert(
|
||||||
|
accountId: accountId,
|
||||||
|
messageId: messageId,
|
||||||
|
appliedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueSieveMove(
|
||||||
|
account_model.Account account,
|
||||||
|
Email row,
|
||||||
|
String folder,
|
||||||
|
) async {
|
||||||
|
String destPath;
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
final destMailbox = await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(account.id) & t.name.equals(folder),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (destMailbox == null) {
|
||||||
|
log(
|
||||||
|
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
destPath = destMailbox.path;
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'move',
|
||||||
|
jsonEncode({'src': row.mailboxPath, 'dest': destPath}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
destPath = folder;
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'move',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'mailboxPath': row.mailboxPath,
|
||||||
|
'dest': destPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||||
|
EmailsCompanion(mailboxPath: Value(destPath)),
|
||||||
|
);
|
||||||
|
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
await _updateThread(account.id, destPath, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueSieveDelete(
|
||||||
|
account_model.Account account,
|
||||||
|
Email row,
|
||||||
|
) async {
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'delete',
|
||||||
|
jsonEncode(<String, dynamic>{}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'delete',
|
||||||
|
jsonEncode({'uid': row.uid, 'mailboxPath': row.mailboxPath}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
||||||
|
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueSieveFlagSeen(
|
||||||
|
account_model.Account account,
|
||||||
|
Email row,
|
||||||
|
) async {
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({'seen': true}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'mailboxPath': row.mailboxPath,
|
||||||
|
'seen': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||||
|
const EmailsCompanion(isSeen: Value(true)),
|
||||||
|
);
|
||||||
|
await _updateThread(account.id, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Drains pending changes for [accountId] via the appropriate protocol.
|
/// Drains pending changes for [accountId] via the appropriate protocol.
|
||||||
/// Called at the start of each sync cycle. Returns count of applied changes.
|
/// Called at the start of each sync cycle. Returns count of applied changes.
|
||||||
@override
|
@override
|
||||||
@@ -1990,7 +2271,18 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.go();
|
.go();
|
||||||
applied++;
|
applied++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await _recordChangeError(row, e);
|
if (_isImapNotFoundError(e)) {
|
||||||
|
// Email already gone on the server — treat as success so the
|
||||||
|
// pending change doesn't accumulate or block future changes.
|
||||||
|
await (_db.delete(
|
||||||
|
_db.pendingChanges,
|
||||||
|
)..where((t) => t.id.equals(row.id)))
|
||||||
|
.go();
|
||||||
|
applied++;
|
||||||
|
log('IMAP change ${row.id} skipped: message already gone ($e)');
|
||||||
|
} else {
|
||||||
|
await _recordChangeError(row, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1999,13 +2291,19 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return applied;
|
return applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isImapNotFoundError(Object e) {
|
||||||
|
final s = e.toString().toLowerCase();
|
||||||
|
return s.contains('nonexistent') || s.contains('not found');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _applyPendingChangeImap(
|
Future<void> _applyPendingChangeImap(
|
||||||
imap.ImapClient client,
|
imap.ImapClient client,
|
||||||
PendingChangeRow row,
|
PendingChangeRow row,
|
||||||
) async {
|
) async {
|
||||||
final payload = jsonDecode(row.payload) as Map<String, dynamic>;
|
final payload = jsonDecode(row.payload) as Map<String, dynamic>;
|
||||||
final uid = payload['uid'] as int;
|
final uid = payload['uid'] as int;
|
||||||
final mailboxPath = payload['mailboxPath'] as String;
|
// snooze/unsnooze payloads use 'src' for the source folder; all others use 'mailboxPath'.
|
||||||
|
final mailboxPath = (payload['mailboxPath'] ?? payload['src']) as String;
|
||||||
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||||
await client.selectMailboxByPath(mailboxPath);
|
await client.selectMailboxByPath(mailboxPath);
|
||||||
|
|
||||||
@@ -2144,8 +2442,29 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final until = payload['until'] as String;
|
final until = payload['until'] as String;
|
||||||
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
||||||
final keyword = 'snz:$timestamp';
|
final keyword = 'snz:$timestamp';
|
||||||
final destMailboxId = payload['dest'] as String;
|
var destMailboxId = payload['dest'] as String;
|
||||||
final srcMailboxId = payload['src'] as String;
|
final srcMailboxId = payload['src'] as String;
|
||||||
|
// When the Snoozed folder didn't exist at enqueue time, 'dest' holds
|
||||||
|
// the literal name 'Snoozed' rather than a JMAP mailbox ID. Create it.
|
||||||
|
if (destMailboxId == 'Snoozed') {
|
||||||
|
final createResps = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-snoozed': {'name': 'Snoozed', 'role': 'snoozed'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final createResult = _responseArgs(createResps, 0, 'Mailbox/set');
|
||||||
|
final created = createResult['created'] as Map<String, dynamic>?;
|
||||||
|
final newId = (created?['new-snoozed']
|
||||||
|
as Map<String, dynamic>?)?['id'] as String?;
|
||||||
|
if (newId != null) destMailboxId = newId;
|
||||||
|
}
|
||||||
responses = await jmap.call([
|
responses = await jmap.call([
|
||||||
[
|
[
|
||||||
'Email/set',
|
'Email/set',
|
||||||
@@ -2509,11 +2828,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(
|
// Fetch the full message so enough_mail has MIME headers (including
|
||||||
emailRow.uid,
|
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||||
'BODY.PEEK[${attachment.fetchPartId}]',
|
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||||
);
|
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||||
final msg = fetch.messages.first;
|
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||||
|
final msg = fetch.messages.firstOrNull;
|
||||||
|
if (msg == null) {
|
||||||
|
throw StateError(
|
||||||
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final 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) {
|
||||||
@@ -2526,6 +2851,68 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> fetchRawRfc822(String emailId) async {
|
||||||
|
final emailRow = await (_db.select(
|
||||||
|
_db.emails,
|
||||||
|
)..where((t) => t.id.equals(emailId)))
|
||||||
|
.getSingle();
|
||||||
|
final account = (await _accounts.getAccount(emailRow.accountId))!;
|
||||||
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(account.jmapUrl!),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
final jmapEmailId = emailId.contains(':')
|
||||||
|
? emailId.substring(emailId.indexOf(':') + 1)
|
||||||
|
: emailId;
|
||||||
|
final responses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': [jmapEmailId],
|
||||||
|
'properties': ['id', 'blobId'],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(responses, 0, 'Email/get');
|
||||||
|
final emailData =
|
||||||
|
(result['list'] as List<dynamic>).first as Map<String, dynamic>;
|
||||||
|
final blobId = emailData['blobId'] as String;
|
||||||
|
final bytes = await jmap.downloadBlob(
|
||||||
|
blobId,
|
||||||
|
name: 'email.eml',
|
||||||
|
type: 'message/rfc822',
|
||||||
|
);
|
||||||
|
return utf8.decode(bytes, allowMalformed: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
|
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||||
|
final msg = fetch.messages.firstOrNull;
|
||||||
|
if (msg == null) {
|
||||||
|
throw StateError(
|
||||||
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return msg.renderMessage();
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<model.Email>> searchEmailsGlobal(
|
Future<List<model.Email>> searchEmailsGlobal(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
@@ -2536,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
|
|
||||||
final sql = accountId != null
|
final sql = accountId != null
|
||||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
|
||||||
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
|
||||||
final variables = accountId != null
|
final variables = accountId != null
|
||||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||||
: [Variable<String>(ftsQuery)];
|
: [Variable<String>(ftsQuery)];
|
||||||
@@ -2548,18 +2935,151 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final emailRows = await Future.wait(
|
final emailRows = await Future.wait(
|
||||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final noteRows = await _searchEmailsByNotes(accountId, null, query);
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final merged = <model.Email>[];
|
||||||
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
|
if (seen.add(e.id)) merged.add(e);
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns emails whose associated notes contain all words from [query].
|
||||||
|
/// Optionally filtered by [accountId] and [mailboxPath].
|
||||||
|
Future<List<model.Email>> _searchEmailsByNotes(
|
||||||
|
String? accountId,
|
||||||
|
String? mailboxPath,
|
||||||
|
String query,
|
||||||
|
) async {
|
||||||
|
final words =
|
||||||
|
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||||
|
if (words.isEmpty) return [];
|
||||||
|
|
||||||
|
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
|
||||||
|
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
|
||||||
|
|
||||||
|
final extraConditions = StringBuffer();
|
||||||
|
final extraVars = <Variable<String>>[];
|
||||||
|
if (accountId != null) {
|
||||||
|
extraConditions.write(' AND e.account_id = ?');
|
||||||
|
extraVars.add(Variable<String>(accountId));
|
||||||
|
}
|
||||||
|
if (mailboxPath != null) {
|
||||||
|
extraConditions.write(' AND e.mailbox_path = ?');
|
||||||
|
extraVars.add(Variable<String>(mailboxPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
final sql = 'SELECT DISTINCT e.* FROM emails e'
|
||||||
|
' JOIN email_notes n ON n.message_id = e.message_id'
|
||||||
|
' AND n.account_id = e.account_id'
|
||||||
|
' WHERE $noteConditions$extraConditions'
|
||||||
|
' ORDER BY e.received_at DESC LIMIT 50';
|
||||||
|
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
sql,
|
||||||
|
variables: [...likeVars, ...extraVars],
|
||||||
|
readsFrom: {_db.emails, _db.emailNotes},
|
||||||
|
).get();
|
||||||
|
final emailRows =
|
||||||
|
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
|
||||||
return emailRows.map(_toModel).toList();
|
return emailRows.map(_toModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<model.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
) async {
|
||||||
|
final rows = await (_db.select(_db.emails)
|
||||||
|
..where((t) {
|
||||||
|
final fe = _filterGroup(filter, t);
|
||||||
|
if (accountId == null) return fe;
|
||||||
|
return t.accountId.equals(accountId) & fe;
|
||||||
|
})
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||||
|
..limit(100))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
|
||||||
|
if (group.isEmpty) return const Constant(true);
|
||||||
|
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
|
||||||
|
return switch (group.operator) {
|
||||||
|
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
|
||||||
|
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
|
||||||
|
switch (node) {
|
||||||
|
final FilterLeaf l => _filterLeaf(l, t),
|
||||||
|
final FilterGroup g => _filterGroup(g, t),
|
||||||
|
};
|
||||||
|
|
||||||
|
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
|
||||||
|
final val = leaf.value.toLowerCase();
|
||||||
|
return switch (leaf.field) {
|
||||||
|
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
|
||||||
|
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
|
||||||
|
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
|
||||||
|
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
|
||||||
|
// Size is not stored in the local cache; skip silently.
|
||||||
|
FilterField.size => const Constant(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _jsonLike(
|
||||||
|
GeneratedColumn<String> col,
|
||||||
|
FilterComparison comp,
|
||||||
|
String val,
|
||||||
|
) =>
|
||||||
|
switch (comp) {
|
||||||
|
FilterComparison.contains => col.like('%$val%'),
|
||||||
|
FilterComparison.is_ => col.like('%"email":"$val"%'),
|
||||||
|
FilterComparison.matches => col.like(_globToLike(val)),
|
||||||
|
_ => const Constant(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
Expression<bool> _textLike(
|
||||||
|
GeneratedColumn<String> col,
|
||||||
|
FilterComparison comp,
|
||||||
|
String val,
|
||||||
|
) =>
|
||||||
|
switch (comp) {
|
||||||
|
FilterComparison.contains => col.like('%$val%'),
|
||||||
|
FilterComparison.is_ => col.like(val),
|
||||||
|
FilterComparison.matches => col.like(_globToLike(val)),
|
||||||
|
_ => const Constant(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
static String _globToLike(String glob) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < glob.length; i++) {
|
||||||
|
final ch = glob[i];
|
||||||
|
if (ch == '%' || ch == '_') {
|
||||||
|
buf.write('\\$ch');
|
||||||
|
} else if (ch == '*') {
|
||||||
|
buf.write('%');
|
||||||
|
} else if (ch == '?') {
|
||||||
|
buf.write('_');
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a user query string into an FTS5 match expression.
|
/// Converts a user query string into an FTS5 match expression.
|
||||||
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
||||||
/// partial words still match. Special FTS5 characters are stripped.
|
/// partial words still match. Special FTS5 characters are stripped.
|
||||||
static String _toFtsQuery(String query) {
|
static String _toFtsQuery(String query) {
|
||||||
final words = query
|
final words = query
|
||||||
.trim()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'[^\w]+'))
|
||||||
.where((w) => w.isNotEmpty)
|
|
||||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
|
||||||
.where((w) => w.isNotEmpty)
|
.where((w) => w.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
if (words.isEmpty) return '';
|
if (words.isEmpty) return '';
|
||||||
@@ -2590,68 +3110,113 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
Future<List<model.EmailAddress>> searchAddresses(
|
||||||
|
String? accountId,
|
||||||
|
String query, {
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
final pattern = '%${query.toLowerCase()}%';
|
||||||
|
|
||||||
|
// Addresses we deliberately wrote to (sent folder) should appear before
|
||||||
|
// addresses that happened to email us (inbox/other folders).
|
||||||
|
final sentMailboxes = await (_db.select(_db.mailboxes)
|
||||||
|
..where((t) {
|
||||||
|
Expression<bool> cond = t.role.equals('sent');
|
||||||
|
if (accountId != null) {
|
||||||
|
cond = t.accountId.equals(accountId) & cond;
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}))
|
||||||
|
.get();
|
||||||
|
final sentPaths = {for (final m in sentMailboxes) m.path};
|
||||||
|
|
||||||
|
final rows = await (_db.select(_db.emails)
|
||||||
|
..where((t) {
|
||||||
|
Expression<bool> cond = const Constant(true);
|
||||||
|
if (accountId != null) cond = t.accountId.equals(accountId);
|
||||||
|
cond = cond &
|
||||||
|
(t.fromJson.like(pattern) |
|
||||||
|
t.toAddresses.like(pattern) |
|
||||||
|
t.ccJson.like(pattern));
|
||||||
|
return cond;
|
||||||
|
})
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||||
|
..limit(100))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Two passes: sent-folder rows first (prioritise recipients we chose),
|
||||||
|
// then other rows (senders who contacted us).
|
||||||
|
final sortedRows = [
|
||||||
|
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
|
||||||
|
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
|
||||||
|
];
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final results = <model.EmailAddress>[];
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
for (final row in sortedRows) {
|
||||||
|
final isSent = sentPaths.contains(row.mailboxPath);
|
||||||
|
final fields = isSent
|
||||||
|
? [row.toAddresses, row.ccJson, row.fromJson]
|
||||||
|
: [row.fromJson, row.toAddresses, row.ccJson];
|
||||||
|
for (final jsonStr in fields) {
|
||||||
|
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||||
|
for (final e in list) {
|
||||||
|
final map = e as Map<String, dynamic>;
|
||||||
|
final addr = model.EmailAddress(
|
||||||
|
name: map['name'] as String?,
|
||||||
|
email: map['email'] as String,
|
||||||
|
);
|
||||||
|
if ((addr.email.toLowerCase().contains(lowerQuery) ||
|
||||||
|
(addr.name?.toLowerCase().contains(lowerQuery) ?? false)) &&
|
||||||
|
seen.add(addr.email.toLowerCase())) {
|
||||||
|
results.add(addr);
|
||||||
|
if (results.length >= limit) return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
// Results are limited to emails already synced into the local SQLite FTS5
|
||||||
|
// index; call syncEmails first to ensure the index is up-to-date.
|
||||||
Future<List<model.Email>> searchEmails(
|
Future<List<model.Email>> searchEmails(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async {
|
) async {
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
final ftsQuery = _toFtsQuery(query);
|
||||||
final password = await _accounts.getPassword(accountId);
|
if (ftsQuery.isEmpty) return [];
|
||||||
final client = await _imapConnect(
|
|
||||||
account,
|
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
_effectiveUsername(account),
|
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||||
password,
|
' ORDER BY e.received_at DESC LIMIT 50';
|
||||||
|
final variables = [
|
||||||
|
Variable<String>(ftsQuery),
|
||||||
|
Variable<String>(accountId),
|
||||||
|
Variable<String>(mailboxPath),
|
||||||
|
];
|
||||||
|
|
||||||
|
final queryRows = await _db
|
||||||
|
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||||
|
final emailRows = await Future.wait(
|
||||||
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
await client.selectMailboxByPath(mailboxPath);
|
|
||||||
final terms =
|
|
||||||
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
|
||||||
final searchCriteria = terms.map((term) {
|
|
||||||
final escaped = term.replaceAll('"', '\\"');
|
|
||||||
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
|
|
||||||
}).join(' ');
|
|
||||||
final result = await client.uidSearchMessages(
|
|
||||||
searchCriteria: searchCriteria,
|
|
||||||
);
|
|
||||||
final uids = result.matchingSequence?.toList() ?? [];
|
|
||||||
if (uids.isEmpty) return [];
|
|
||||||
|
|
||||||
final fetch = await client.uidFetchMessages(
|
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||||
imap.MessageSequence.fromIds(uids, isUid: true),
|
|
||||||
'(UID FLAGS ENVELOPE)',
|
final seen = <String>{};
|
||||||
);
|
final merged = <model.Email>[];
|
||||||
return fetch.messages
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
.where((msg) => msg.uid != null && msg.envelope != null)
|
if (seen.add(e.id)) merged.add(e);
|
||||||
.map((msg) {
|
|
||||||
final envelope = msg.envelope!;
|
|
||||||
final uid = msg.uid!;
|
|
||||||
final emailId = '$accountId:$uid';
|
|
||||||
return model.Email(
|
|
||||||
id: emailId,
|
|
||||||
accountId: accountId,
|
|
||||||
mailboxPath: mailboxPath,
|
|
||||||
uid: uid,
|
|
||||||
subject: envelope.subject,
|
|
||||||
sentAt: envelope.date,
|
|
||||||
receivedAt: envelope.date ?? DateTime.now(),
|
|
||||||
from: _toAddressList(envelope.from),
|
|
||||||
to: _toAddressList(envelope.to),
|
|
||||||
cc: _toAddressList(envelope.cc),
|
|
||||||
isSeen: msg.flags?.contains(r'\Seen') ?? false,
|
|
||||||
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
|
||||||
hasAttachment: msg.hasAttachments(),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
}
|
||||||
|
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
|
|
||||||
(addresses ?? const [])
|
|
||||||
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Computes a stable threadId from RFC 2822 headers.
|
/// Computes a stable threadId from RFC 2822 headers.
|
||||||
@@ -2751,6 +3316,27 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
htmlBody: row.htmlBody,
|
htmlBody: row.htmlBody,
|
||||||
attachments: _parseAttachments(row.attachmentsJson),
|
attachments: _parseAttachments(row.attachmentsJson),
|
||||||
headers: _parseHeaders(row.headersJson),
|
headers: _parseHeaders(row.headersJson),
|
||||||
|
mimeTree: _parseMimeTree(row.mimeTreeJson),
|
||||||
|
);
|
||||||
|
|
||||||
|
model.MimePart? _parseMimeTree(String? jsonStr) {
|
||||||
|
if (jsonStr == null || jsonStr.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
return _mimePartFromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model.MimePart _mimePartFromJson(Map<String, dynamic> m) => model.MimePart(
|
||||||
|
contentType: m['contentType'] as String? ?? 'application/octet-stream',
|
||||||
|
filename: m['filename'] as String?,
|
||||||
|
size: m['size'] as int?,
|
||||||
|
encoding: m['encoding'] as String?,
|
||||||
|
children: ((m['children'] as List<dynamic>?) ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(_mimePartFromJson)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
List<model.EmailHeader> _parseHeaders(String? jsonStr) {
|
List<model.EmailHeader> _parseHeaders(String? jsonStr) {
|
||||||
@@ -2827,14 +3413,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
await (_db.delete(_db.emails)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.emails,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(_db.pendingChanges)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.pendingChanges,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(_db.syncStates)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.syncStates,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2842,3 +3431,36 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recursively converts an [imap.MimePart] into a JSON-serialisable map.
|
||||||
|
Map<String, dynamic> _mimePartToJson(imap.MimePart part) {
|
||||||
|
final ct = part.getHeaderContentType();
|
||||||
|
final disposition = part.getHeaderContentDisposition();
|
||||||
|
final rawEncoding =
|
||||||
|
part.getHeader('content-transfer-encoding')?.firstOrNull?.value;
|
||||||
|
final encoding = rawEncoding?.split(';').first.trim().toLowerCase();
|
||||||
|
return {
|
||||||
|
'contentType': ct?.mediaType.text ?? 'application/octet-stream',
|
||||||
|
'filename': disposition?.filename ?? ct?.parameters['name'],
|
||||||
|
'size': disposition?.size,
|
||||||
|
'encoding': encoding,
|
||||||
|
'children': (part.parts ?? []).map(_mimePartToJson).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a JSON string representing the MIME tree of [msg].
|
||||||
|
String _buildMimeTreeJson(imap.MimeMessage msg) =>
|
||||||
|
jsonEncode(_mimePartToJson(msg));
|
||||||
|
|
||||||
|
/// Converts a JMAP `bodyStructure` object into the same JSON format used by
|
||||||
|
/// [_mimePartToJson], so [_parseMimeTree] can deserialise it uniformly.
|
||||||
|
Map<String, dynamic> _jmapBodyStructureToJson(Map<String, dynamic> m) => {
|
||||||
|
'contentType': m['type'] as String? ?? 'application/octet-stream',
|
||||||
|
'filename': m['name'],
|
||||||
|
'size': m['size'],
|
||||||
|
'encoding': null,
|
||||||
|
'children': ((m['subParts'] as List<dynamic>?) ?? [])
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(_jmapBodyStructureToJson)
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final mailboxes = await client.listMailboxes(recursive: true);
|
final mailboxes = await client.listMailboxes(recursive: true);
|
||||||
|
|
||||||
|
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||||
|
// folders the server doesn't tag with a special-use attribute.
|
||||||
|
final existingRows = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.accountId.equals(account.id)))
|
||||||
|
.get();
|
||||||
|
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||||
|
|
||||||
for (final mb in mailboxes) {
|
for (final mb in mailboxes) {
|
||||||
final path = mb.path;
|
final path = mb.path;
|
||||||
final id = '${account.id}:$path';
|
final id = '${account.id}:$path';
|
||||||
@@ -96,6 +105,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
log('STATUS skipped for $path: $e');
|
log('STATUS skipped for $path: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the server-assigned role when available; fall back to the
|
||||||
|
// existing DB role so that manually-created folders (e.g. a user
|
||||||
|
// who just created their Archive folder) keep their role across syncs
|
||||||
|
// when the IMAP server does not expose a special-use attribute.
|
||||||
|
final role = _imapRole(mb) ?? existingRoles[id];
|
||||||
|
|
||||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
MailboxesCompanion.insert(
|
MailboxesCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -104,7 +119,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
name: mb.name,
|
name: mb.name,
|
||||||
unreadCount: Value(unread),
|
unreadCount: Value(unread),
|
||||||
totalCount: Value(total),
|
totalCount: Value(total),
|
||||||
role: Value(_imapRole(mb)),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -306,8 +321,127 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {
|
Future<void> clearForResync(String accountId) async {
|
||||||
await (_db.delete(_db.mailboxes)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<model.Mailbox> createMailboxWithRole(
|
||||||
|
String accountId,
|
||||||
|
String name,
|
||||||
|
String role,
|
||||||
|
) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
return _createMailboxWithRoleImap(account, password, name, role);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
return _createMailboxWithRoleJmap(account, password, name, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<model.Mailbox> createMailbox(String accountId, String name) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
return _createMailboxWithRoleImap(account, password, name, null);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
return _createMailboxWithRoleJmap(account, password, name, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.Mailbox> _createMailboxWithRoleImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String name,
|
||||||
|
String? role,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await client.createMailbox(name);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
final id = '${account.id}:$name';
|
||||||
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
accountId: account.id,
|
||||||
|
path: name,
|
||||||
|
name: name,
|
||||||
|
role: Value(role),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals(id)))
|
||||||
|
.getSingle();
|
||||||
|
return _toModel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.Mailbox> _createMailboxWithRoleJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String name,
|
||||||
|
String? role,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
|
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
||||||
|
}
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
final responses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-mailbox': {
|
||||||
|
'name': name,
|
||||||
|
if (role != null) 'role': role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(responses, 0, 'Mailbox/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newId =
|
||||||
|
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
|
||||||
|
if (newId == null) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to create mailbox "$name": server returned no ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final dbId = '${account.id}:$newId';
|
||||||
|
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: dbId,
|
||||||
|
accountId: account.id,
|
||||||
|
path: newId,
|
||||||
|
name: name,
|
||||||
|
role: Value(role),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals(dbId)))
|
||||||
|
.getSingle();
|
||||||
|
return _toModel(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||||
|
|
||||||
|
const _notesFolder = 'Notes';
|
||||||
|
const _headerNoteFor = 'X-SharedInbox-Note-For';
|
||||||
|
const _headerNoteId = 'X-SharedInbox-Note-Id';
|
||||||
|
|
||||||
|
class NoteRepositoryImpl implements NoteRepository {
|
||||||
|
NoteRepositoryImpl(
|
||||||
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn imapConnect = connectImap,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) : _imapConnect = imapConnect,
|
||||||
|
_httpClient = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accounts;
|
||||||
|
final ImapConnectFn _imapConnect;
|
||||||
|
final http.Client _httpClient;
|
||||||
|
|
||||||
|
String _effectiveUsername(account_model.Account account) =>
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
|
||||||
|
// ── Observe (local cache) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
|
||||||
|
return (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||||
|
)
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toModel).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync (server → local cache) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncNotes(String accountId, String messageId) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _syncNotesImap(account, password, messageId);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _syncNotesJmap(account, password, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder doesn't exist — nothing to sync.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||||
|
final searchResult = await client.uidSearchMessages(
|
||||||
|
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
|
||||||
|
);
|
||||||
|
final uids = searchResult.matchingSequence?.toList() ?? [];
|
||||||
|
|
||||||
|
if (uids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
|
||||||
|
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final msg in fetch.messages) {
|
||||||
|
final uid = msg.uid;
|
||||||
|
if (uid == null) continue;
|
||||||
|
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: msg.decodeTextPlainPart() ?? '',
|
||||||
|
serverId: uid.toString(),
|
||||||
|
createdAt: msg.decodeDate() ?? DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes (deleted on the server).
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (mailboxId == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'filter': {'inMailbox': mailboxId},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final ids = List<String>.from(
|
||||||
|
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final getResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': ids,
|
||||||
|
'properties': [
|
||||||
|
'id',
|
||||||
|
'receivedAt',
|
||||||
|
'textBody',
|
||||||
|
'bodyValues',
|
||||||
|
'header:$_headerNoteFor:asText',
|
||||||
|
'header:$_headerNoteId:asText',
|
||||||
|
],
|
||||||
|
'fetchTextBodyValues': true,
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list =
|
||||||
|
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final e in list) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
|
||||||
|
if (noteFor != messageId) continue;
|
||||||
|
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
final jmapEmailId = m['id'] as String;
|
||||||
|
|
||||||
|
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
|
||||||
|
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
|
||||||
|
var noteText = '';
|
||||||
|
if (textBodyParts.isNotEmpty) {
|
||||||
|
final partId =
|
||||||
|
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
|
||||||
|
if (partId != null) {
|
||||||
|
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
|
||||||
|
as String? ??
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final createdAt =
|
||||||
|
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: noteText,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes.
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) & t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addNote(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
final noteId = _generateId();
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _addNoteImap(account, password, messageId, noteId, text);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _addNoteJmap(account, password, messageId, noteId, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.createMailbox(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Already exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
final builder = imap.MessageBuilder()
|
||||||
|
..subject = 'Note'
|
||||||
|
..text = text;
|
||||||
|
builder.addHeader(_headerNoteFor, messageId);
|
||||||
|
builder.addHeader(_headerNoteId, noteId);
|
||||||
|
final mime = builder.buildMimeMessage();
|
||||||
|
|
||||||
|
final appendResult = await client.appendMessage(
|
||||||
|
mime,
|
||||||
|
targetMailboxPath: _notesFolder,
|
||||||
|
);
|
||||||
|
final uidList =
|
||||||
|
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||||
|
final serverId = (uidList != null && uidList.isNotEmpty)
|
||||||
|
? uidList.first.toString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: serverId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
|
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
|
||||||
|
|
||||||
|
const bodyPartId = '1';
|
||||||
|
final setResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-note': {
|
||||||
|
'mailboxIds': {mailboxId: true},
|
||||||
|
'subject': 'Note',
|
||||||
|
'keywords': {r'$seen': true},
|
||||||
|
'headers': [
|
||||||
|
{'name': _headerNoteFor, 'value': ' $messageId'},
|
||||||
|
{'name': _headerNoteId, 'value': ' $noteId'},
|
||||||
|
],
|
||||||
|
'bodyValues': {
|
||||||
|
bodyPartId: {
|
||||||
|
'value': text,
|
||||||
|
'isEncodingProblem': false,
|
||||||
|
'isTruncated': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'textBody': [
|
||||||
|
{'partId': bodyPartId, 'type': 'text/plain'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
final result = _responseArgs(setResp, 0, 'Email/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newEmail = created?['new-note'] as Map<String, dynamic>?;
|
||||||
|
final jmapEmailId = newEmail?['id'] as String? ?? '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteNote(String noteId) async {
|
||||||
|
final noteRow = await (_db.select(_db.emailNotes)
|
||||||
|
..where((t) => t.id.equals(noteId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (noteRow == null) return;
|
||||||
|
|
||||||
|
final account = await _accounts.getAccount(noteRow.accountId);
|
||||||
|
if (account == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _deleteNoteImap(account, password, noteRow);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _deleteNoteJmap(account, password, noteRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
final uid = int.tryParse(noteRow.serverId);
|
||||||
|
if (uid != null) {
|
||||||
|
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||||
|
await client.uidMarkDeleted(seq);
|
||||||
|
await client.uidExpunge(seq);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder gone or message already deleted — clean up locally.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noteRow.serverId.isNotEmpty) {
|
||||||
|
await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'destroy': [noteRow.serverId],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JMAP helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/get',
|
||||||
|
{'accountId': jmap.accountId, 'ids': null},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
|
||||||
|
for (final m in list) {
|
||||||
|
final map = m as Map<String, dynamic>;
|
||||||
|
if (map['name'] == _notesFolder) return map['id'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final existing = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-notes': {'name': _notesFolder},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(resp, 0, 'Mailbox/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
|
||||||
|
return newMailbox?['id'] as String? ?? _notesFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _responseArgs(
|
||||||
|
List<dynamic> responses,
|
||||||
|
int index,
|
||||||
|
String expectedMethod,
|
||||||
|
) {
|
||||||
|
final triple = responses[index] as List<dynamic>;
|
||||||
|
final method = triple[0] as String;
|
||||||
|
if (method == 'error') {
|
||||||
|
final err = triple[1] as Map<String, dynamic>;
|
||||||
|
throw JmapException('$expectedMethod error: ${err['type']}');
|
||||||
|
}
|
||||||
|
return triple[1] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailNote _toModel(EmailNoteRow row) => EmailNote(
|
||||||
|
id: row.id,
|
||||||
|
accountId: row.accountId,
|
||||||
|
messageId: row.messageId,
|
||||||
|
noteText: row.noteText,
|
||||||
|
serverId: row.serverId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generates a random UUID v4.
|
||||||
|
static String _generateId() {
|
||||||
|
final rng = math.Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
|
||||||
|
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
|
||||||
|
'-${hex.substring(20)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
|
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
// Remove existing entry for same query (deduplication).
|
// Remove existing entry for same query (deduplication).
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
await (_db.delete(
|
||||||
..where((t) => t.query.equals(trimmed)))
|
_db.searchHistoryEntries,
|
||||||
|
)..where((t) => t.query.equals(trimmed)))
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
@@ -43,8 +44,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
if (keepIds.isNotEmpty) {
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
await (_db.delete(
|
||||||
..where((t) => t.id.isNotIn(keepIds)))
|
_db.searchHistoryEntries,
|
||||||
|
)..where((t) => t.id.isNotIn(keepIds)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
/// Drift-backed implementation of [ShareKeyRepository].
|
||||||
|
///
|
||||||
|
/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a
|
||||||
|
/// new key pair is created or looked up.
|
||||||
|
class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||||
|
ShareKeyRepositoryImpl(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareKeyMaterial> createKeyPair() async {
|
||||||
|
await _pruneExpired();
|
||||||
|
|
||||||
|
final material = await ShareEncryptionService.generateKeyPair();
|
||||||
|
final keyIdHex = _hex(material.keyId);
|
||||||
|
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||||
|
|
||||||
|
await _db.into(_db.shareKeys).insert(
|
||||||
|
ShareKeysCompanion.insert(
|
||||||
|
id: keyIdHex,
|
||||||
|
publicKey: base64.encode(material.publicKeyBytes),
|
||||||
|
privateKey: base64.encode(material.privateKeyBytes),
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId) async {
|
||||||
|
await _pruneExpired();
|
||||||
|
|
||||||
|
final keyIdHex = _hex(keyId);
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.shareKeys,
|
||||||
|
)..where((t) => t.id.equals(keyIdHex)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
if (row == null) return null;
|
||||||
|
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
|
||||||
|
|
||||||
|
return ShareKeyMaterial(
|
||||||
|
keyId: keyId,
|
||||||
|
publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)),
|
||||||
|
privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pruneExpired() async {
|
||||||
|
await (_db.delete(
|
||||||
|
_db.shareKeys,
|
||||||
|
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _hex(Uint8List bytes) =>
|
||||||
|
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
required String accountId,
|
required String accountId,
|
||||||
required bool success,
|
required bool success,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? stackTrace,
|
||||||
|
bool isPermanent = false,
|
||||||
required String protocol,
|
required String protocol,
|
||||||
required int emailsFetched,
|
required int emailsFetched,
|
||||||
required int emailsSkipped,
|
required int emailsSkipped,
|
||||||
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
result: success ? 'ok' : 'error',
|
result: success ? 'ok' : 'error',
|
||||||
errorMessage: Value(errorMessage),
|
errorMessage: Value(errorMessage),
|
||||||
|
errorStackTrace: Value(stackTrace),
|
||||||
|
isPermanent: Value(isPermanent),
|
||||||
protocol: Value(protocol),
|
protocol: Value(protocol),
|
||||||
itemsSynced: Value(emailsFetched),
|
itemsSynced: Value(emailsFetched),
|
||||||
emailsSkipped: Value(emailsSkipped),
|
emailsSkipped: Value(emailsSkipped),
|
||||||
@@ -49,6 +53,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
fetched: Value(s.fetched),
|
fetched: Value(s.fetched),
|
||||||
skipped: Value(s.skipped),
|
skipped: Value(s.skipped),
|
||||||
bytesTransferred: Value(s.bytesTransferred),
|
bytesTransferred: Value(s.bytesTransferred),
|
||||||
|
durationMs: Value(s.duration?.inMilliseconds),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
result: r.result,
|
result: r.result,
|
||||||
errorMessage: r.errorMessage,
|
errorMessage: r.errorMessage,
|
||||||
|
stackTrace: r.errorStackTrace,
|
||||||
|
isPermanent: r.isPermanent,
|
||||||
protocol: r.protocol,
|
protocol: r.protocol,
|
||||||
emailsFetched: r.itemsSynced,
|
emailsFetched: r.itemsSynced,
|
||||||
emailsSkipped: r.emailsSkipped,
|
emailsSkipped: r.emailsSkipped,
|
||||||
@@ -90,6 +97,9 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
|||||||
fetched: m.fetched,
|
fetched: m.fetched,
|
||||||
skipped: m.skipped,
|
skipped: m.skipped,
|
||||||
bytesTransferred: m.bytesTransferred,
|
bytesTransferred: m.bytesTransferred,
|
||||||
|
duration: m.durationMs != null
|
||||||
|
? Duration(milliseconds: m.durationMs!)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
|
||||||
|
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
||||||
|
UserPreferencesRepositoryImpl(this._db);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
static const _rowId = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<pref.UserPreferences> observePreferences() {
|
||||||
|
return (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)..where((t) => t.id.equals(_rowId)))
|
||||||
|
.watchSingleOrNull()
|
||||||
|
.map(_rowToModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateMenuPosition(pref.MenuPosition position) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
menuPosition: Value(position.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
mailViewButtonPosition: Value(position.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateAfterMailViewAction(
|
||||||
|
pref.AfterMailViewAction action,
|
||||||
|
) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
afterMailViewAction: Value(action.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
prefetchMode: Value(mode.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
bodyCacheLimitMb: Value(mb),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<String>> observeTrustedImageSenders() {
|
||||||
|
return (_db.select(_db.imageTrustedSenders)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map((r) => r.senderEmail).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||||
|
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
|
||||||
|
ImageTrustedSendersCompanion(
|
||||||
|
senderEmail: Value(senderEmail.toLowerCase()),
|
||||||
|
addedAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||||
|
await (_db.delete(_db.imageTrustedSenders)
|
||||||
|
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
||||||
|
if (row == null) return const pref.UserPreferences();
|
||||||
|
return pref.UserPreferences(
|
||||||
|
menuPosition: pref.MenuPosition.values.firstWhere(
|
||||||
|
(e) => e.name == row.menuPosition,
|
||||||
|
orElse: () => pref.MenuPosition.bottom,
|
||||||
|
),
|
||||||
|
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
|
||||||
|
(e) => e.name == row.mailViewButtonPosition,
|
||||||
|
orElse: () => pref.MenuPosition.bottom,
|
||||||
|
),
|
||||||
|
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
|
||||||
|
(e) => e.name == row.afterMailViewAction,
|
||||||
|
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||||
|
),
|
||||||
|
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
|
||||||
|
bodyCacheLimitMb: row.bodyCacheLimitMb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
@@ -19,16 +25,21 @@ import 'package:sharedinbox/core/services/undo_service.dart';
|
|||||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
|
import 'package:sharedinbox/data/db/database.dart'
|
||||||
|
hide Email, EmailBody, UserPreferences;
|
||||||
|
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
|
|
||||||
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
||||||
@@ -60,6 +71,10 @@ final accountRepositoryProvider = Provider<AccountRepository>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final shareKeyRepositoryProvider = Provider<ShareKeyRepository>((ref) {
|
||||||
|
return ShareKeyRepositoryImpl(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
||||||
return MailboxRepositoryImpl(
|
return MailboxRepositoryImpl(
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
@@ -89,12 +104,13 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchHistoryRepositoryProvider =
|
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
||||||
Provider<SearchHistoryRepository>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final syncLogRepositoryProvider = Provider((ref) {
|
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,8 +139,10 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
final isSyncingProvider =
|
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
ref,
|
||||||
|
accountId,
|
||||||
|
) {
|
||||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +173,10 @@ final sieveRepositoryProvider = Provider<SieveRepository>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final localSieveRepositoryProvider = Provider<LocalSieveRepository>((ref) {
|
||||||
|
return LocalSieveRepository(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
||||||
return ConnectionTestServiceImpl(
|
return ConnectionTestServiceImpl(
|
||||||
ref.watch(httpClientProvider),
|
ref.watch(httpClientProvider),
|
||||||
@@ -169,12 +191,9 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider =
|
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
||||||
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
|
UndoService.new,
|
||||||
final service = UndoService(ref);
|
);
|
||||||
unawaited(service.init());
|
|
||||||
return service;
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -183,20 +202,50 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
|||||||
EmailDetailNotifier.new,
|
EmailDetailNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
class EmailDetailNotifier
|
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||||
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
EmailDetailNotifier(this._emailId);
|
||||||
|
final String _emailId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<(Email?, EmailBody)> build(String emailId) async {
|
Future<(Email?, EmailBody)> build() async {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
repo.getEmail(emailId),
|
repo.getEmail(_emailId),
|
||||||
repo.getEmailBody(emailId),
|
repo.getEmailBody(_emailId),
|
||||||
]);
|
]);
|
||||||
unawaited(repo.setFlag(emailId, seen: true));
|
unawaited(repo.setFlag(_emailId, seen: true));
|
||||||
|
final header = results[0] as Email?;
|
||||||
|
if (header != null) {
|
||||||
|
unawaited(_prefetchNextEmailBody(repo, header));
|
||||||
|
}
|
||||||
return (results[0] as Email?, results[1] as EmailBody);
|
return (results[0] as Email?, results[1] as EmailBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _prefetchNextEmailBody(
|
||||||
|
EmailRepository repo,
|
||||||
|
Email header,
|
||||||
|
) async {
|
||||||
|
final prefs = ref.read(userPreferencesProvider).value;
|
||||||
|
final action =
|
||||||
|
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||||
|
if (action != AfterMailViewAction.nextMessage) return;
|
||||||
|
|
||||||
|
final threads =
|
||||||
|
await repo.observeThreads(header.accountId, header.mailboxPath).first;
|
||||||
|
final currentIndex = threads.indexWhere(
|
||||||
|
(t) => t.emailIds.contains(_emailId),
|
||||||
|
);
|
||||||
|
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
|
||||||
|
|
||||||
|
final nextId = threads[currentIndex + 1].latestEmailId;
|
||||||
|
await repo.getEmailBody(nextId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
|
||||||
|
return ref.watch(accountRepositoryProvider).observeAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
final accountByIdProvider =
|
final accountByIdProvider =
|
||||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||||
@@ -217,3 +266,41 @@ final accountConnectionStatusProvider =
|
|||||||
.read(connectionTestServiceProvider)
|
.read(connectionTestServiceProvider)
|
||||||
.testConnection(account, password);
|
.testConnection(account, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
final trustedImageSendersProvider =
|
||||||
|
StreamProvider.autoDispose<List<String>>((ref) {
|
||||||
|
return ref
|
||||||
|
.watch(userPreferencesRepositoryProvider)
|
||||||
|
.observeTrustedImageSenders();
|
||||||
|
});
|
||||||
|
|
||||||
|
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
||||||
|
return NoteRepositoryImpl(
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(accountRepositoryProvider),
|
||||||
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
|
||||||
|
return ref.watch(dbProvider).loadInstalledVersions();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
||||||
|
final notesProvider =
|
||||||
|
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
||||||
|
(ref, params) =>
|
||||||
|
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,20 +3,32 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/router.dart';
|
import 'package:sharedinbox/ui/router.dart';
|
||||||
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
||||||
|
import 'package:stack_trace/stack_trace.dart' as stack_trace;
|
||||||
|
|
||||||
void main({List<Override> overrides = const []}) async {
|
void main({List<Override> overrides = const []}) {
|
||||||
unawaited(
|
unawaited(
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
() async {
|
() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Dart's async machinery propagates stack traces in chain format
|
||||||
|
// (with '===== asynchronous gap =====' separators). Flutter's
|
||||||
|
// StackFrame parser asserts on those lines, so strip them first.
|
||||||
|
FlutterError.demangleStackTrace = (StackTrace s) {
|
||||||
|
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
|
||||||
|
if (s is stack_trace.Trace) return s.vmTrace;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||||
ErrorWidget.builder = (details) => CrashScreen(
|
ErrorWidget.builder = (details) => CrashScreen(
|
||||||
exception: details.exception,
|
exception: details.exception,
|
||||||
@@ -38,19 +50,35 @@ void main({List<Override> overrides = const []}) async {
|
|||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await initNotifications();
|
await initNotifications();
|
||||||
await registerBackgroundSync();
|
await registerBackgroundSync();
|
||||||
|
await _registerPrefetchTaskFromStoredPrefs();
|
||||||
}
|
}
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
// This handler runs in the parent zone — runApp cannot be called here.
|
||||||
// Catch unhandled async errors.
|
// Framework errors are already handled by FlutterError.onError above.
|
||||||
runApp(CrashScreen(exception: error, stackTrace: stack));
|
(error, stack) => FlutterError.reportError(
|
||||||
},
|
FlutterErrorDetails(exception: error, stack: stack),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the stored prefetch preference and registers the WorkManager task
|
||||||
|
/// with the correct network constraint for it. Opens and immediately closes
|
||||||
|
/// a temporary DB connection; safe because initDatabasePath() has already run.
|
||||||
|
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
|
||||||
|
final db = AppDatabase();
|
||||||
|
try {
|
||||||
|
final row = await db.select(db.userPreferences).getSingleOrNull();
|
||||||
|
final mode = PrefetchMode.fromString(row?.prefetchMode);
|
||||||
|
await registerBodyPrefetchTask(mode);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SharedInboxApp extends ConsumerStatefulWidget {
|
class SharedInboxApp extends ConsumerStatefulWidget {
|
||||||
const SharedInboxApp({super.key});
|
const SharedInboxApp({super.key});
|
||||||
|
|
||||||
@@ -58,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -65,15 +95,21 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||||
ref.read(syncManagerProvider).start();
|
ref.read(syncManagerProvider).start();
|
||||||
ref.read(reliabilityRunnerProvider).start();
|
ref.read(reliabilityRunnerProvider).start();
|
||||||
|
if (_kGitHash.isNotEmpty) {
|
||||||
|
unawaited(
|
||||||
|
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'SharedInbox',
|
title: 'sharedinbox.de',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
@@ -81,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||||
@@ -16,15 +22,22 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/inbox',
|
||||||
routes: [
|
routes: [
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (ctx, state, child) => UndoShell(child: child),
|
builder: (ctx, state, child) => UndoShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/inbox',
|
||||||
|
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
builder: (ctx, state) => const AccountListScreen(),
|
builder: (ctx, state) => const AccountListScreen(),
|
||||||
@@ -33,14 +46,44 @@ final router = GoRouter(
|
|||||||
path: 'add',
|
path: 'add',
|
||||||
builder: (ctx, state) => const AddAccountScreen(),
|
builder: (ctx, state) => const AddAccountScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'receive',
|
||||||
|
builder: (ctx, state) => const AccountReceiveScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'send',
|
||||||
|
builder: (ctx, state) => const AccountSendScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'undo-log',
|
path: 'undo-log',
|
||||||
builder: (ctx, state) => const UndoLogScreen(),
|
builder: (ctx, state) => const UndoLogScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':actionId',
|
||||||
|
builder: (ctx, state) => UndoLogDetailScreen(
|
||||||
|
action: state.extra as UndoAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
builder: (ctx, state) => const ChangeLogScreen(),
|
builder: (ctx, state) => const ChangeLogScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'about',
|
||||||
|
builder: (ctx, state) => const AboutScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'preferences',
|
||||||
|
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'trusted-senders',
|
||||||
|
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||||
|
highlightedSender: state.extra as String?,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
@@ -65,6 +108,21 @@ final router = GoRouter(
|
|||||||
script: state.extra as SieveScript?,
|
script: state.extra as SieveScript?,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sieve/local',
|
||||||
|
builder: (ctx, state) => SieveScriptsScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
isLocal: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: ':accountId/sieve/local/edit',
|
||||||
|
builder: (ctx, state) => SieveScriptEditScreen(
|
||||||
|
accountId: state.pathParameters['accountId']!,
|
||||||
|
script: state.extra as SieveScript?,
|
||||||
|
isLocal: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/search',
|
path: ':accountId/search',
|
||||||
builder: (ctx, state) =>
|
builder: (ctx, state) =>
|
||||||
@@ -129,6 +187,12 @@ final router = GoRouter(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/bug-report',
|
||||||
|
builder: (ctx, state) => BugReportScreen(
|
||||||
|
emailId: state.uri.queryParameters['emailId'],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AboutScreen> createState() => _AboutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Future<String?> _deviceModelFuture;
|
||||||
|
late final Stream<List<Account>> _accountsStream;
|
||||||
|
String? _deviceModel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
|
_deviceModelFuture = getDeviceModel();
|
||||||
|
unawaited(
|
||||||
|
_deviceModelFuture.then((model) {
|
||||||
|
if (mounted) setState(() => _deviceModel = model);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copyToClipboard(
|
||||||
|
BuildContext context,
|
||||||
|
int imapCount,
|
||||||
|
int jmapCount,
|
||||||
|
) async {
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
String? deviceModel;
|
||||||
|
try {
|
||||||
|
deviceModel = await _deviceModelFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text: buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Copied to clipboard'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Could not open browser.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createIssue(
|
||||||
|
BuildContext context,
|
||||||
|
int imapCount,
|
||||||
|
int jmapCount,
|
||||||
|
) async {
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
String? deviceModel;
|
||||||
|
try {
|
||||||
|
deviceModel = await _deviceModelFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final body = Uri.encodeComponent(
|
||||||
|
buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Could not open browser.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder<List<Account>>(
|
||||||
|
stream: _accountsStream,
|
||||||
|
builder: (context, accountSnapshot) {
|
||||||
|
final accounts = accountSnapshot.data ?? [];
|
||||||
|
final imapCount =
|
||||||
|
accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount =
|
||||||
|
accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('About')),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<PackageInfo>(
|
||||||
|
future: _packageInfoFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return Markdown(
|
||||||
|
data: buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: snapshot.data,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
),
|
||||||
|
selectable: true,
|
||||||
|
onTapLink: (text, href, title) {
|
||||||
|
if (href != null) {
|
||||||
|
unawaited(_launchUrl(context, Uri.parse(href)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy info'),
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
_copyToClipboard(context, imapCount, jmapCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
|
label: const Text('Public issue'),
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
_createIssue(context, imapCount, jmapCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.feedback_outlined),
|
||||||
|
label: const Text('Report bug'),
|
||||||
|
onPressed: () => context.push('/bug-report'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/services/update_service.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class AccountListScreen extends ConsumerWidget {
|
class AccountListScreen extends ConsumerWidget {
|
||||||
const AccountListScreen({super.key});
|
const AccountListScreen({super.key});
|
||||||
@@ -14,7 +16,7 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('SharedInbox'),
|
title: const Text('sharedinbox.de'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
@@ -29,10 +31,18 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
const DrawerHeader(
|
const DrawerHeader(
|
||||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||||
child: Text(
|
child: Text(
|
||||||
'SharedInbox',
|
'sharedinbox.de',
|
||||||
style: TextStyle(color: Colors.white, fontSize: 24),
|
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
|
title: const Text('Receive accounts'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/receive'));
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.history),
|
leading: const Icon(Icons.history),
|
||||||
title: const Text('Undo Log'),
|
title: const Text('Undo Log'),
|
||||||
@@ -49,37 +59,47 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
unawaited(context.push('/accounts/changelog'));
|
unawaited(context.push('/accounts/changelog'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('About'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context); // Close drawer
|
||||||
|
unawaited(context.push('/accounts/about'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Preferences'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context); // Close drawer
|
||||||
|
unawaited(context.push('/accounts/preferences'));
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: StreamBuilder(
|
body: Column(
|
||||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
children: [
|
||||||
builder: (ctx, snap) {
|
const _UpdateBanner(),
|
||||||
if (!snap.hasData) {
|
Expanded(
|
||||||
return const Center(child: CircularProgressIndicator());
|
child: StreamBuilder(
|
||||||
}
|
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||||
final accounts = snap.data!;
|
builder: (ctx, snap) {
|
||||||
if (accounts.isEmpty) {
|
if (!snap.hasData) {
|
||||||
return Center(
|
return const Center(child: CircularProgressIndicator());
|
||||||
child: Column(
|
}
|
||||||
mainAxisSize: MainAxisSize.min,
|
final accounts = snap.data!;
|
||||||
children: [
|
if (accounts.isEmpty) {
|
||||||
const Text('No accounts yet.'),
|
return const _OnboardingView();
|
||||||
const SizedBox(height: 12),
|
}
|
||||||
FilledButton.icon(
|
return ListView.builder(
|
||||||
onPressed: () => context.push('/accounts/add'),
|
itemCount: accounts.length,
|
||||||
icon: const Icon(Icons.add),
|
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||||
label: const Text('Add account'),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: accounts.length,
|
|
||||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.push('/accounts/add'),
|
onPressed: () => context.push('/accounts/add'),
|
||||||
@@ -100,20 +120,80 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
final health = ref.watch(syncHealthProvider(account.id));
|
final health = ref.watch(syncHealthProvider(account.id));
|
||||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||||
|
|
||||||
return ListTile(
|
return Column(
|
||||||
leading: const Icon(Icons.account_circle),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text(account.displayName),
|
children: [
|
||||||
subtitle: Column(
|
ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
leading: const Icon(Icons.account_circle),
|
||||||
children: [
|
title: Text(account.displayName),
|
||||||
Text('${account.email}\n$typeLabel'),
|
subtitle: Text('${account.email}\n$typeLabel'),
|
||||||
const SizedBox(height: 4),
|
isThreeLine: true,
|
||||||
health.when(
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
status.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
data: (_) =>
|
||||||
|
const Icon(Icons.check_circle, color: Colors.green),
|
||||||
|
error: (e, _) => Tooltip(
|
||||||
|
message: e.toString(),
|
||||||
|
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_AccountAction>(
|
||||||
|
onSelected: (action) => _onAction(context, action),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.syncLog,
|
||||||
|
child: Text('Sync log'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.verifySync,
|
||||||
|
child: Text('Verify sync health'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.forceSync,
|
||||||
|
child: Text('Force full sync'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.edit,
|
||||||
|
child: Text('Edit'),
|
||||||
|
),
|
||||||
|
if (_sieveSupported(account))
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersRemote,
|
||||||
|
child: Text('Server email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersLocal,
|
||||||
|
child: Text('Local email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.send,
|
||||||
|
child: Text('Send accounts'),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.delete,
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||||
|
child: health.when(
|
||||||
data: (h) {
|
data: (h) {
|
||||||
if (h == null) return const Text('Sync health: Not verified yet');
|
if (h == null) return const Text('Sync health: Not verified yet');
|
||||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
const Text('Sync health: '),
|
const Text('Sync health: '),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -122,7 +202,13 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
h.isHealthy
|
||||||
|
? 'Healthy'
|
||||||
|
: _formatDiscrepancies(h.discrepancySummary),
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -130,54 +216,8 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
loading: () => const Text('Sync health: checking...'),
|
loading: () => const Text('Sync health: checking...'),
|
||||||
error: (e, _) => Text('Sync health error: $e'),
|
error: (e, _) => Text('Sync health error: $e'),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
isThreeLine: true,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
status.when(
|
|
||||||
loading: () => const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
|
||||||
error: (e, _) => Tooltip(
|
|
||||||
message: e.toString(),
|
|
||||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuButton<_AccountAction>(
|
|
||||||
onSelected: (action) => _onAction(context, action),
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.syncLog,
|
|
||||||
child: Text('Sync log'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.verifySync,
|
|
||||||
child: Text('Verify sync health'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.edit,
|
|
||||||
child: Text('Edit'),
|
|
||||||
),
|
|
||||||
if (_sieveSupported(account))
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFilters,
|
|
||||||
child: Text('Email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.delete,
|
|
||||||
child: Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,16 +234,53 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Starting sync verification...')),
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Starting sync verification...'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case _AccountAction.forceSync:
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Force full sync?'),
|
||||||
|
content: const Text(
|
||||||
|
'This clears all locally-cached emails and mailboxes for this '
|
||||||
|
'account and immediately re-downloads everything from the server. '
|
||||||
|
'Previously viewed email content will not need to be re-downloaded.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Force sync'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && context.mounted) {
|
||||||
|
await ProviderScope.containerOf(
|
||||||
|
context,
|
||||||
|
).read(syncManagerProvider).forceResync(account.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case _AccountAction.edit:
|
case _AccountAction.edit:
|
||||||
await context.push('/accounts/${account.id}/edit');
|
await context.push('/accounts/${account.id}/edit');
|
||||||
break;
|
break;
|
||||||
case _AccountAction.emailFilters:
|
case _AccountAction.emailFiltersRemote:
|
||||||
await context.push('/accounts/${account.id}/sieve');
|
await context.push('/accounts/${account.id}/sieve');
|
||||||
break;
|
break;
|
||||||
|
case _AccountAction.emailFiltersLocal:
|
||||||
|
await context.push('/accounts/${account.id}/sieve/local');
|
||||||
|
break;
|
||||||
|
case _AccountAction.send:
|
||||||
|
await context.push('/accounts/send');
|
||||||
|
break;
|
||||||
case _AccountAction.delete:
|
case _AccountAction.delete:
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -233,7 +310,146 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
String _formatDiscrepancies(String? summary) {
|
||||||
|
if (summary == null) return 'Discrepancies found';
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(summary) as Map<String, dynamic>;
|
||||||
|
var missingLocally = 0;
|
||||||
|
var missingOnServer = 0;
|
||||||
|
var flagMismatches = 0;
|
||||||
|
for (final v in decoded.values) {
|
||||||
|
final m = v as Map<String, dynamic>;
|
||||||
|
missingLocally += (m['missingLocally'] as int? ?? 0);
|
||||||
|
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
||||||
|
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
||||||
|
}
|
||||||
|
final parts = <String>[];
|
||||||
|
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
|
||||||
|
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
|
||||||
|
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
|
||||||
|
if (parts.isEmpty) return 'Discrepancies found';
|
||||||
|
return 'Discrepancies found (${parts.join(', ')})';
|
||||||
|
} catch (_) {
|
||||||
|
return 'Discrepancies found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OnboardingView extends StatelessWidget {
|
||||||
|
const _OnboardingView();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.mail_outline,
|
||||||
|
size: 64,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Welcome to sharedinbox.de',
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Get started in three steps:',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const _Step(
|
||||||
|
number: '1',
|
||||||
|
title: 'Add an account',
|
||||||
|
description: 'Connect your IMAP or JMAP email account.',
|
||||||
|
),
|
||||||
|
const _Step(
|
||||||
|
number: '2',
|
||||||
|
title: 'Wait for sync',
|
||||||
|
description:
|
||||||
|
'sharedinbox.de downloads your messages in the background.',
|
||||||
|
),
|
||||||
|
const _Step(
|
||||||
|
number: '3',
|
||||||
|
title: 'Open your inbox',
|
||||||
|
description:
|
||||||
|
'Tap the account to browse mailboxes and read emails.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.push('/accounts/add'),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add account'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Step extends StatelessWidget {
|
||||||
|
const _Step({
|
||||||
|
required this.number,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String number;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: theme.colorScheme.primaryContainer,
|
||||||
|
child: Text(
|
||||||
|
number,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: theme.textTheme.titleSmall),
|
||||||
|
Text(description, style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _AccountAction {
|
||||||
|
syncLog,
|
||||||
|
verifySync,
|
||||||
|
forceSync,
|
||||||
|
edit,
|
||||||
|
emailFiltersRemote,
|
||||||
|
emailFiltersLocal,
|
||||||
|
send,
|
||||||
|
delete,
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||||
///
|
///
|
||||||
@@ -245,3 +461,31 @@ bool _sieveSupported(Account account) {
|
|||||||
if (account.type == AccountType.jmap) return true;
|
if (account.type == AccountType.jmap) return true;
|
||||||
return account.manageSieveAvailable != false;
|
return account.manageSieveAvailable != false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shown on Linux desktop when a newer build is available on the server.
|
||||||
|
class _UpdateBanner extends ConsumerWidget {
|
||||||
|
const _UpdateBanner();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final update = ref.watch(updateInfoProvider);
|
||||||
|
return update.when(
|
||||||
|
data: (info) {
|
||||||
|
if (info == null) return const SizedBox.shrink();
|
||||||
|
return MaterialBanner(
|
||||||
|
content: Text('Update available: ${info.latestVersion}'),
|
||||||
|
leading: const Icon(Icons.system_update),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
unawaited(launchUrl(Uri.parse(info.downloadUrl))),
|
||||||
|
child: const Text('Download'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
/// Receiving side of the secure account-sharing flow.
|
||||||
|
///
|
||||||
|
/// Step 1 – generates an X25519 key pair with a 20-minute lifetime and shows
|
||||||
|
/// the public key as a QR code to be scanned by the sender.
|
||||||
|
///
|
||||||
|
/// Step 2 – scans the encrypted-accounts QR code shown by the sender, decrypts
|
||||||
|
/// it using the private key, and imports the accounts.
|
||||||
|
class AccountReceiveScreen extends ConsumerStatefulWidget {
|
||||||
|
const AccountReceiveScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AccountReceiveScreen> createState() =>
|
||||||
|
_AccountReceiveScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
||||||
|
|
||||||
|
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||||
|
_Step _step = _Step.generatingKey;
|
||||||
|
ShareKeyMaterial? _keyMaterial;
|
||||||
|
DateTime? _keyExpiresAt;
|
||||||
|
String? _pubKeyQr;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _scannerActive = false;
|
||||||
|
|
||||||
|
MobileScannerController? _scannerController;
|
||||||
|
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||||
|
// MissingPluginException on some Android builds).
|
||||||
|
bool _scannerFailed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_generateKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
final ctrl = _scannerController;
|
||||||
|
if (ctrl != null) unawaited(ctrl.dispose());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _generateKey() async {
|
||||||
|
try {
|
||||||
|
final repo = ref.read(shareKeyRepositoryProvider);
|
||||||
|
final material = await repo.createKeyPair();
|
||||||
|
final qr = ShareEncryptionService.encodePublicKeyQr(
|
||||||
|
material.keyId,
|
||||||
|
material.publicKeyBytes,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_keyMaterial = material;
|
||||||
|
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||||
|
_pubKeyQr = qr;
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_step = _Step.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startScanning() {
|
||||||
|
setState(() {
|
||||||
|
_step = _Step.scanning;
|
||||||
|
_scannerActive = true;
|
||||||
|
});
|
||||||
|
if (_cameraScanSupported()) {
|
||||||
|
unawaited(_initScanner());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||||
|
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||||
|
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||||
|
// the plugin exists but something else failed — the MobileScanner widget
|
||||||
|
// will surface those via its own error builder.
|
||||||
|
Future<void> _initScanner() async {
|
||||||
|
bool available = false;
|
||||||
|
try {
|
||||||
|
await const MethodChannel(
|
||||||
|
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||||
|
).invokeMethod<int>('state');
|
||||||
|
available = true;
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Plugin not registered on this device; text fallback will be shown.
|
||||||
|
} catch (_) {
|
||||||
|
// Plugin registered but state check failed; let the scanner widget
|
||||||
|
// handle it via its errorBuilder.
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (available) {
|
||||||
|
setState(() => _scannerController = MobileScannerController());
|
||||||
|
} else {
|
||||||
|
setState(() => _scannerFailed = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onScanned(String rawValue) async {
|
||||||
|
if (!_scannerActive) return;
|
||||||
|
_scannerActive = false;
|
||||||
|
await _scannerController?.stop();
|
||||||
|
|
||||||
|
setState(() => _step = _Step.importing);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final material = _keyMaterial!;
|
||||||
|
final accounts = await ShareEncryptionService.decryptAccounts(
|
||||||
|
qrString: rawValue,
|
||||||
|
privateKeyBytes: material.privateKeyBytes,
|
||||||
|
publicKeyBytes: material.publicKeyBytes,
|
||||||
|
keyId: material.keyId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repo = ref.read(accountRepositoryProvider);
|
||||||
|
for (final ap in accounts) {
|
||||||
|
final account = Account.fromJson(ap.accountJson);
|
||||||
|
final newAccount = Account(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
displayName: account.displayName,
|
||||||
|
email: account.email,
|
||||||
|
username: account.username,
|
||||||
|
type: account.type,
|
||||||
|
imapHost: account.imapHost,
|
||||||
|
imapPort: account.imapPort,
|
||||||
|
imapSsl: account.imapSsl,
|
||||||
|
smtpHost: account.smtpHost,
|
||||||
|
smtpPort: account.smtpPort,
|
||||||
|
smtpSsl: account.smtpSsl,
|
||||||
|
manageSieveHost: account.manageSieveHost,
|
||||||
|
manageSievePort: account.manageSievePort,
|
||||||
|
manageSieveSsl: account.manageSieveSsl,
|
||||||
|
jmapUrl: account.jmapUrl,
|
||||||
|
);
|
||||||
|
await repo.addAccount(newAccount, ap.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _step = _Step.done);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${accounts.length} account${accounts.length == 1 ? '' : 's'} successfully.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = _friendlyError(e);
|
||||||
|
_scannerActive = false;
|
||||||
|
// Let user retry from the pubkey step.
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_friendlyError(e)),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _friendlyError(Object e) {
|
||||||
|
final s = e.toString();
|
||||||
|
if (s.contains('expired') || s.contains('older than')) {
|
||||||
|
return 'The QR code has expired. Ask the sender to generate a new one.';
|
||||||
|
}
|
||||||
|
if (s.contains('Key ID mismatch') || s.contains('Unknown')) {
|
||||||
|
return 'QR code does not match this session. Regenerate the public key and try again.';
|
||||||
|
}
|
||||||
|
if (s.contains('authentication') ||
|
||||||
|
s.contains('mac') ||
|
||||||
|
s.contains('SecretBox')) {
|
||||||
|
return 'Authentication failed — the QR code may have been tampered with.';
|
||||||
|
}
|
||||||
|
return 'Import failed: $s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Receive accounts')),
|
||||||
|
body: switch (_step) {
|
||||||
|
_Step.generatingKey => const Center(child: CircularProgressIndicator()),
|
||||||
|
_Step.showingPubKey => _buildPubKeyView(context),
|
||||||
|
_Step.scanning => _buildScannerView(context),
|
||||||
|
_Step.importing => const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Importing accounts…'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_Step.done => const Center(
|
||||||
|
child: Icon(Icons.check_circle, size: 64, color: Colors.green),
|
||||||
|
),
|
||||||
|
_Step.error => Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Error: $_errorMessage'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPubKeyView(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Step 1 of 2 — Show this QR code to the sender',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'The sender scans this code, selects the account(s) to transfer, '
|
||||||
|
'and shows an encrypted QR code. Then come back here for step 2.',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: QrImageView(
|
||||||
|
key: const Key('pubKeyQrCode'),
|
||||||
|
data: _pubKeyQr!,
|
||||||
|
size: 260,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy public key'),
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(Clipboard.setData(ClipboardData(text: _pubKeyQr!)));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Public key copied to clipboard')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
FilledButton.icon(
|
||||||
|
key: const Key('scanEncryptedButton'),
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
label: const Text('Step 2 — Scan encrypted QR code'),
|
||||||
|
onPressed: _startScanning,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScannerView(BuildContext context) {
|
||||||
|
// Fall back to text input when the platform has no camera support or when
|
||||||
|
// the scanner plugin fails to initialise at runtime (MissingPluginException).
|
||||||
|
if (!_cameraScanSupported() || _scannerFailed) {
|
||||||
|
return _buildTextFallbackView(context);
|
||||||
|
}
|
||||||
|
if (_scannerController == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
MobileScanner(
|
||||||
|
controller: _scannerController!,
|
||||||
|
onDetect: (capture) {
|
||||||
|
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||||
|
if (raw != null) unawaited(_onScanned(raw));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: const Text(
|
||||||
|
'Point the camera at the encrypted QR code from the sender\'s device',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 32,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
final ctrl = _scannerController;
|
||||||
|
if (ctrl != null) unawaited(ctrl.dispose());
|
||||||
|
_scannerController = null;
|
||||||
|
setState(() {
|
||||||
|
_scannerActive = false;
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextFallbackView(BuildContext context) {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Paste the encrypted code from the sender\'s device',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const Key('encryptedCodeField'),
|
||||||
|
controller: ctrl,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Encrypted code',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: 'sharedinbox.de:encrypted-accounts:v1:…',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final text = ctrl.text.trim();
|
||||||
|
if (text.isNotEmpty) unawaited(_onScanned(text));
|
||||||
|
},
|
||||||
|
child: const Text('Import'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_scannerActive = false;
|
||||||
|
_step = _Step.showingPubKey;
|
||||||
|
}),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _cameraScanSupported() =>
|
||||||
|
Platform.isAndroid ||
|
||||||
|
Platform.isIOS ||
|
||||||
|
Platform.isMacOS ||
|
||||||
|
Platform.isWindows;
|
||||||
|
|
||||||
|
class _ExpiryHint extends StatefulWidget {
|
||||||
|
const _ExpiryHint({required this.expiresAt});
|
||||||
|
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExpiryHint> createState() => _ExpiryHintState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpiryHintState extends State<_ExpiryHint> {
|
||||||
|
late Timer _timer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRemaining() {
|
||||||
|
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
||||||
|
if (remaining.isNegative) return 'expired';
|
||||||
|
final minutes = remaining.inMinutes;
|
||||||
|
final seconds = remaining.inSeconds % 60;
|
||||||
|
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'This key expires in ${_formatRemaining()}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||