Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 c4634936ae feat(S2): validate IMAP/SMTP hostnames against injection characters
Add validateHostname / validateOptionalHostname helpers to host_utils.dart
that reject values containing @, /, \, or control characters. Wire them
into AddAccountScreen and EditAccountScreen for all host fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:45:37 +02:00
319 changed files with 1494 additions and 29245 deletions
-18
View File
@@ -1,18 +0,0 @@
# 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
-2
View File
@@ -14,7 +14,5 @@ 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
-25
View File
@@ -1,25 +0,0 @@
# 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
+35 -7
View File
@@ -1,14 +1,42 @@
name: CI name: CI
on: [push, pull_request]
on:
push:
branches: [main]
pull_request:
jobs: jobs:
check: check:
name: Full Project Check name: Full Project Check
runs-on: ubuntu-latest # Match the label of your self-hosted runner
runs-on: self-hosted
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env: - name: Enable Nix flakes
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} run: |
run: scripts/setup_dagger_remote.sh mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
- name: Run Full Check Suite - name: Run Full Check Suite
run: task check-dagger # Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally.
# '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
-291
View File
@@ -1,291 +0,0 @@
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:
- 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 workflow run where deploy-playstore actually succeeded
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
# every run and the fallback diff to only cover HEAD~1..HEAD.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
base_api = f"{server}/api/v1/repos/{repo}/actions"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("status") == "success"
]
# Walk runs newest-first; pick the first one where deploy-playstore
# actually ran (conclusion=success), not just skipped.
for run in runs:
run_id = run.get("id")
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Deploy to Play Store" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
print("")
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:
- 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:
- 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:
- 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: 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
-122
View File
@@ -1,122 +0,0 @@
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:
- 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:
- 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["Ready"]] if "Ready" 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
+50
View File
@@ -0,0 +1,50 @@
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
-30
View File
@@ -1,30 +0,0 @@
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
+23 -19
View File
@@ -1,8 +1,6 @@
name: Update Website name: Deploy Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -13,31 +11,37 @@ on:
jobs: jobs:
deploy: deploy:
name: Build & Update Website name: Build & Deploy Website
runs-on: ubuntu-latest runs-on: self-hosted
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Check runner tools - name: Enable Nix flakes
run: | run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } mkdir -p ~/.config/nix
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
- name: Setup Dagger Remote Engine - name: Setup SSH
env: env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
run: scripts/setup_dagger_remote.sh run: |
if [ -n "$SSH_PRIVATE_KEY" ]; then
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
else
echo "Error: WEBSITE_SSH_PRIVATE_KEY secret is not set."
exit 1
fi
- name: Build & Update Website - name: Deploy
env: env:
DAGGER_NO_NAG: "1" SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
run: task publish-website SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: nix develop --command task website-deploy
- name: Verify Website - name: Verify
env: run: nix develop --command task website-verify
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh
-46
View File
@@ -1,46 +0,0 @@
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
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 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.44.0" "flutter": "3.41.6"
} }
+153
View File
@@ -0,0 +1,153 @@
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
+2 -19
View File
@@ -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,8 +29,7 @@ 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/ intentionally tracked so that android/app/src/main/java/io/flutter/plugins/
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
.android/ .android/
Android/ Android/
.gradle/ .gradle/
@@ -59,10 +58,6 @@ linux/flutter/generated_plugins.cmake
.flutter-plugins-dependencies .flutter-plugins-dependencies
.metadata .metadata
# --- Python ---
__pycache__/
*.pyc
# --- Tools & Cache --- # --- Tools & Cache ---
.fvm/ .fvm/
fvm/ fvm/
@@ -103,8 +98,6 @@ 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/
@@ -112,14 +105,4 @@ website/content/builds/[0-9]*/
.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
+3
View File
@@ -0,0 +1,3 @@
[submodule "website/themes/PaperMod"]
path = website/themes/PaperMod
url = https://github.com/adityatelange/hugo-PaperMod.git
-29
View File
@@ -10,19 +10,8 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/guettli/pre-commit-branch-up-to-date
rev: v0.0.5
hooks:
- id: branch-up-to-date
- 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
@@ -35,21 +24,3 @@ repos:
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)" && nix develop --command scripts/pre_commit_check.sh'
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)" && nix develop --command task check-ci-images'
pass_filenames: false
files: ^(ci/main\.go|\.fvmrc)$
-44
View File
@@ -1,49 +1,5 @@
# 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 moves to `loop/code-done` |
**State machine:**
```
loop/plan → loop/plan-in-progress → loop/plan-done
↘ NeedSupervisor (on failure)
loop/code → loop/code-in-progress → loop/code-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 coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
- Planning agents only post a comment — they do NOT write code or open PRs.
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
**Typical lifecycle for a new feature:**
```
1. Create issue
2. Add label loop/plan → agent writes plan as comment
3. Review plan, request changes or approve
4. Add label loop/code → agent implements + opens PR
5. Review PR, merge
6. Close issue
```
## Code conventions ## Code conventions
- Avoid `else`, use "early return". - Avoid `else`, use "early return".
-183
View File
@@ -1,183 +0,0 @@
# 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.
-192
View File
@@ -1,192 +0,0 @@
# 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 -->
-5
View File
@@ -216,8 +216,3 @@ test/
- **Settings** — list and remove accounts - **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change - **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send - **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
-206
View File
@@ -1,206 +0,0 @@
# 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.
+25 -377
View File
@@ -1,9 +1,6 @@
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)
@@ -125,16 +122,6 @@ tasks:
cmds: cmds:
- fvm dart format lib test - fvm dart format lib test
check-mocks:
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
deps: [_preflight, _pub-get]
sources:
- lib/**/*.dart
- test/**/*.dart
- pubspec.yaml
cmds:
- scripts/check_mocks_fresh.sh
analyze-fix: analyze-fix:
desc: Auto-fix lint issues with dart fix --apply desc: Auto-fix lint issues with dart fix --apply
deps: [_preflight] deps: [_preflight]
@@ -174,184 +161,23 @@ tasks:
cmds: cmds:
- fvm flutter test - fvm flutter test
test-backend: integration:
desc: Backend tests against a local Stalwart mail server (via Dagger) desc: Integration tests against a local Stalwart mail server
deps: [_flutter-check]
sources:
- lib/**/*.dart
- test/integration/**/*.dart
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. test-backend - stalwart-dev/test.sh
integration-ui: integration-ui:
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger) desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
deps: [_preflight, _linux-deps-check, _pub-get]
sources:
- lib/**/*.dart
- integration_test/app_e2e_test.dart
cmds: cmds:
- dagger call --progress=plain -q -m ci --source=. test-integration - stalwart-dev/integration_ui_test.sh
sync-reliability:
desc: Run sync reliability runner (via Dagger)
cmds:
- 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:
- 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 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) && 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:
- 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 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 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) && 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:
- 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)
@@ -390,106 +216,7 @@ 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 --dart-define=GIT_HASH=$(git rev-parse --short HEAD) - scripts/silent_on_success.sh fvm flutter build linux --release --no-pub
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: Build and deploy the Go bugreport server to the webserver
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:
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
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:
@@ -539,19 +266,19 @@ 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 --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled" - ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android-bundle: deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm) desc: Build release AAB and upload to Play Store internal track
deps: [build-android-bundle-local] deps: [build-android-bundle]
preconditions: preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON" - sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set" msg: "PLAY_STORE_CONFIG_JSON is not set"
cmds: cmds:
- python3 scripts/deploy_playstore.py - python3 scripts/deploy_playstore.py
build-android-bundle-local: build-android-bundle:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger) desc: Build a release App Bundle (AAB)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog] deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
method: timestamp method: timestamp
sources: sources:
@@ -561,7 +288,7 @@ 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) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled" - 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"
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
@@ -588,7 +315,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, _codegen] deps: [_preflight, _linux-deps-check, _pub-get]
cmds: cmds:
- fvm flutter run -d linux --no-pub - fvm flutter run -d linux --no-pub
@@ -604,12 +331,6 @@ tasks:
cmds: cmds:
- fvm dart run scripts/check_coverage.dart - fvm dart run scripts/check_coverage.dart
check-coverage:
desc: Run unit+widget tests with coverage, then fail if the gate is not met
deps: [test]
cmds:
- task: coverage
website-dev: website-dev:
desc: Run Hugo development server desc: Run Hugo development server
cmds: cmds:
@@ -629,81 +350,19 @@ 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 \
--exclude='*.apk' \ -e "ssh -o StrictHostKeyChecking=no" \
--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 — analyze + unit tests + widget tests (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks] deps: [analyze, test, check-hygiene]
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
@@ -719,16 +378,11 @@ 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
_integrations: _integrations:
internal: true internal: true
run: once run: once
cmds: cmds:
- task: test-backend - task: integration
- task: integration-ui - task: integration-ui
ci-logs: ci-logs:
@@ -736,12 +390,6 @@ 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
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]
+1
View File
@@ -4,6 +4,7 @@ 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.
+3 -8
View File
@@ -13,13 +13,10 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlin { kotlinOptions {
compilerOptions { jvmTarget = JavaVersion.VERSION_17.toString()
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
signingConfigs { signingConfigs {
@@ -38,7 +35,7 @@ android {
applicationId = "de.sharedinbox.mua" applicationId = "de.sharedinbox.mua"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 23 minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@@ -68,8 +65,6 @@ flutter {
} }
dependencies { dependencies {
// 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.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
-5
View File
@@ -1,10 +1,5 @@
<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.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application <application
android:label="sharedinbox" android:label="sharedinbox"
android:name="${applicationName}" android:name="${applicationName}"
@@ -1,89 +0,0 @@
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);
}
}
}
-1
View File
@@ -1,3 +1,2 @@
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
+1 -1
View File
@@ -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.5-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")
View File
-4
View File
@@ -1,4 +0,0 @@
/dagger.gen.go linguist-generated
/internal/dagger/** linguist-generated
/internal/querybuilder/** linguist-generated
/internal/telemetry/** linguist-generated
-5
View File
@@ -1,5 +0,0 @@
/dagger.gen.go
/internal/dagger
/internal/querybuilder
/internal/telemetry
/.env
-7
View File
@@ -1,7 +0,0 @@
{
"name": "ci",
"engineVersion": "v0.20.8",
"sdk": {
"source": "go"
}
}
-5
View File
@@ -1,5 +0,0 @@
module dagger/ci
go 1.26.2
require golang.org/x/sync v0.20.0
-2
View File
@@ -1,2 +0,0 @@
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-982
View File
@@ -1,982 +0,0 @@
package main
import (
"context"
"dagger/ci/internal/dagger"
"encoding/json"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
// patchAabScript patches android:versionCode in an AAB's compiled manifest proto.
// It strips META-INF/ (old signature) and repacks the ZIP. No external dependencies.
const patchAabScript = `#!/usr/bin/env python3
import sys, zipfile
MANIFEST = "base/manifest/AndroidManifest.xml"
VERSION_CODE_RID = 0x0101021b
def _vr(b, p):
n = s = 0
while True:
c = b[p]; p += 1; n |= (c & 127) << s
if not (c & 128): return n, p
s += 7
def _ve(n):
r = []
while n > 127: r.append((n & 127) | 128); n >>= 7
return bytes(r + [n])
def _parse(d):
p = 0
while p < len(d):
tag, p = _vr(d, p); fn, wt = tag >> 3, tag & 7
if wt == 0: v, p = _vr(d, p); yield fn, 0, v
elif wt == 2: ln, p = _vr(d, p); yield fn, 2, d[p:p+ln]; p += ln
elif wt == 5: yield fn, 5, d[p:p+4]; p += 4 # fixed32
elif wt == 1: yield fn, 1, d[p:p+8]; p += 8 # fixed64
else: raise ValueError(f"wire type {wt}")
def _enc(fn, wt, v):
t = _ve((fn << 3) | wt)
if wt == 0: return t + _ve(v)
if wt in (1, 5): return t + v # fixed-width, pass bytes as-is
return t + _ve(len(v)) + v
def _patch_prim(d, vc):
# Patch int_decimal_value (field 6) or int_hexadecimal_value (field 7),
# whichever is present — AAPT2 may use either.
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(fn, 0, vc) if (fn in (6, 7) and wt == 0) else _enc(fn, wt, v)
return bytes(out)
def _patch_item(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(7, 2, _patch_prim(v, vc)) if fn == 7 else _enc(fn, wt, v)
return bytes(out)
def _has_rid(d):
return any(fn == 5 and wt == 0 and v == VERSION_CODE_RID for fn, wt, v in _parse(d))
def _patch_attr(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
if fn == 3 and wt == 2: out += _enc(3, 2, str(vc).encode())
elif fn == 6 and wt == 2: out += _enc(6, 2, _patch_item(v, vc))
else: out += _enc(fn, wt, v)
return bytes(out)
def _patch_elem(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(4, 2, _patch_attr(v, vc)) if (fn == 4 and _has_rid(v)) else _enc(fn, wt, v)
return bytes(out)
def _patch_node(d, vc):
out = bytearray()
for fn, wt, v in _parse(d):
out += _enc(1, 2, _patch_elem(v, vc)) if fn == 1 else _enc(fn, wt, v)
return bytes(out)
def _dump_proto(d, depth=0, limit=3):
"""Print proto field structure for debugging."""
pad = " " * depth
for fn, wt, v in _parse(d):
if wt == 0:
print(f"{pad}[{fn}] varint={v} (0x{v:x})")
elif wt == 2:
print(f"{pad}[{fn}] bytes len={len(v)}")
if depth < limit:
_dump_proto(v, depth + 1, limit)
elif wt == 5:
print(f"{pad}[{fn}] fixed32={v.hex()}")
elif wt == 1:
print(f"{pad}[{fn}] fixed64={v.hex()}")
def _read_vc_from_node(d):
"""Read versionCode from XmlNode proto bytes. Returns int or None."""
for fn, wt, v in _parse(d):
if fn == 1 and wt == 2: # XmlElement
for efn, ewt, attr in _parse(v):
if efn == 4 and ewt == 2 and _has_rid(attr): # XmlAttribute with versionCode RID
for afn, awt, item in _parse(attr):
if afn == 6 and awt == 2: # compiled_value (Item)
for ifn, iwt, prim in _parse(item):
if ifn == 7 and iwt == 2: # prim (Primitive)
for pfn, pwt, pv in _parse(prim):
if pfn in (6, 7) and pwt == 0:
return pv
return None
def patch(src, dst, vc):
with zipfile.ZipFile(src) as z:
mf = z.read(MANIFEST)
orig_vc = _read_vc_from_node(mf)
if orig_vc is None:
print("DEBUG: could not find versionCode — dumping manifest proto structure:")
_dump_proto(mf, limit=4)
sys.exit(f"ERROR: versionCode not found in {MANIFEST}")
print(f"Original versionCode in manifest: {orig_vc}")
patched = _patch_node(mf, vc)
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, 'w') as zout:
for info in zin.infolist():
if info.filename.startswith('META-INF/'):
continue # strip old signature; jarsigner re-signs after
d = patched if info.filename == MANIFEST else zin.read(info.filename)
zi = zipfile.ZipInfo(info.filename, info.date_time)
zi.compress_type = info.compress_type
zi.external_attr = info.external_attr
zout.writestr(zi, d)
# Verify the patch actually took effect
with zipfile.ZipFile(dst) as z:
actual = _read_vc_from_node(z.read(MANIFEST))
if actual != vc:
sys.exit(f"ERROR: versionCode patch failed — wrote {vc} but read back {actual} (original was {orig_vc})")
print(f"versionCode={actual} -> {dst}")
if __name__ == "__main__":
if len(sys.argv) != 4:
sys.exit(f"usage: {sys.argv[0]} in.aab out.aab versionCode")
patch(sys.argv[1], sys.argv[2], int(sys.argv[3]))
`
type Ci struct {
Source *dagger.Directory
FlutterVersion string
}
func New(
ctx context.Context,
// +defaultPath=".."
source *dagger.Directory,
) (*Ci, error) {
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
}
var fvmrc struct {
Flutter string `json:"flutter"`
}
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
}
if fvmrc.Flutter == "" {
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
}
return &Ci{
FlutterVersion: fvmrc.Flutter,
Source: source.Filter(dagger.DirectoryFilterOpts{
Include: []string{
".fvmrc",
"lib/",
"test/",
"assets/",
"scripts/",
"pubspec.yaml",
"pubspec.lock",
"analysis_options.yaml",
"linux/",
"android/",
"integration_test/",
"drift_schemas/",
"stalwart-dev/",
"website/",
},
}),
}, nil
}
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
// Its execution cache key is stable until the image, apt packages, or SDK versions change.
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
WithExec([]string{"/bin/sh", "-c",
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
`chown -R ci:ci "$flutter_dir"; ` +
`[ -n "$ANDROID_HOME" ] && chown -R ci:ci "$ANDROID_HOME" || true; ` +
`mkdir -p /src && chown ci:ci /src`}).
WithEnvVariable("PUB_CACHE", "/home/ci/.pub-cache").
WithEnvVariable("HOME", "/home/ci").
WithUser("ci").
WithExec([]string{"/bin/sh", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
}
// Base is the Flutter toolchain container with mutable cache mounts attached.
// Use for Android/Gradle builds that need the Gradle cache.
func (m *Ci) Base() *dagger.Container {
return m.toolchain().
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// pubGetLayer runs flutter pub get with only pubspec.yaml + pubspec.lock as
// inputs, then removes non-deterministic fields from both package_config.json
// and .flutter-plugins-dependencies so the snapshot is byte-for-byte stable
// across runs. Re-executes only when pubspec.yaml or pubspec.lock changes.
// Packages land in the execution-cache snapshot (not a named volume) so that
// dagger prune can reclaim space from stale pubspec.lock snapshots.
func (m *Ci) pubGetLayer() *dagger.Container {
pubspecOnly := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"pubspec.yaml", "pubspec.lock"},
})
return m.toolchain().
WithDirectory("/src", pubspecOnly, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub get >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^(\+|Downloading packages)' "$tmp" || true`}).
WithExec([]string{"python3", "-c",
"import json, os\n" +
"f='.dart_tool/package_config.json'; d=json.load(open(f)); [d.pop(k,None) for k in ('generated','generatorVersion')]; json.dump(d,open(f,'w'))\n" +
"g='.flutter-plugins-dependencies'\n" +
"if os.path.exists(g):\n" +
" d=json.load(open(g)); d.pop('date_created',None); json.dump(d,open(g,'w'))\n"})
}
// codegenBase runs build_runner on the source subset common to all build
// variants (lib/, test/, assets/, pubspec.*), excluding committed generated
// files so the cache key is stable. All setup() calls share this single
// Dagger cache entry, so build_runner compiles only once per pipeline run.
func (m *Ci) codegenBase() *dagger.Container {
codegenSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock"},
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
})
return m.pubGetLayer().
WithDirectory("/src", codegenSrc, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`})
}
// setup overlays platform-specific source files onto the shared codegen base.
// Generated files (*.g.dart, *.mocks.dart) are excluded from the overlay so
// the freshly built output from codegenBase() is not overwritten by stale
// committed copies.
func (m *Ci) setup(src *dagger.Directory) *dagger.Container {
return m.codegenBase().
WithDirectory("/src", src.Filter(dagger.DirectoryFilterOpts{
Exclude: []string{"**/*.g.dart", "**/*.mocks.dart"},
}), dagger.ContainerWithDirectoryOpts{Owner: "ci"})
}
// Setup is the exported variant (CLI / Taskfile). Uses the full check source.
func (m *Ci) Setup() *dagger.Container {
return m.setup(m.checkSrc())
}
// checkSrc is the source subset for static checks and unit tests.
func (m *Ci) checkSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "test/", "assets/", "pubspec.yaml", "pubspec.lock", "analysis_options.yaml", "scripts/"},
})
}
// androidSrc is the source subset for Android builds.
func (m *Ci) androidSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "android/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
})
}
// firebaseSrc is the source subset for Firebase Test Lab builds (app + instrumented tests).
func (m *Ci) firebaseSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "android/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
})
}
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func (m *Ci) androidBase() *dagger.Container {
return m.setup(m.androidSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func (m *Ci) firebaseBase() *dagger.Container {
return m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// linuxSrc is the source subset for Linux builds and integration tests.
func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "linux/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
})
}
// backendSrc is the source subset for IMAP/JMAP backend tests.
func (m *Ci) backendSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "test/", "assets/", "scripts/", "stalwart-dev/", "pubspec.yaml", "pubspec.lock"},
})
}
// integrationSrc is the source subset for UI integration tests (runs on Linux desktop).
func (m *Ci) integrationSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"lib/", "linux/", "integration_test/", "assets/", "pubspec.yaml", "pubspec.lock", "drift_schemas/"},
})
}
// Hugo container for website builds
func (m *Ci) Hugo() *dagger.Container {
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "curl", "tar", "libc6-compat", "libstdc++", "gcompat"}).
WithExec([]string{"curl", "-sL", "https://github.com/gohugoio/hugo/releases/download/v0.152.2/hugo_extended_0.152.2_linux-amd64.tar.gz", "-o", "/tmp/hugo.tar.gz"}).
WithExec([]string{"sh", "-c", "echo '416bcfbdf5f68469ec9644dbe507da50fc21b94b69a125b059d64ed2cb4d8c27 /tmp/hugo.tar.gz' | sha256sum -c -"}).
WithExec([]string{"tar", "-xzf", "/tmp/hugo.tar.gz", "-C", "/usr/local/bin", "hugo"}).
WithExec([]string{"rm", "/tmp/hugo.tar.gz"})
}
// Deploy container for rsync/ssh
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
// Create .ssh with strict permissions before Dagger mounts anything there,
// so the directory is 700 (not Dagger's default 755).
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
// Mount the raw key outside .ssh so Dagger cannot override the directory
// permissions we just set above.
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
// Using Python3 (not tr) changes the Dagger cache key so stale cached
// results from the old tr-based step are not reused.
WithExec([]string{"python3", "-c",
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
}
// Stalwart mail server service for backend and integration tests.
func (m *Ci) Stalwart() *dagger.Service {
stalwartSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"stalwart-dev/"},
})
config := stalwartSrc.Directory("stalwart-dev").File("config.toml")
dataDir := dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "add", "--no-cache", "sqlite"}).
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
WithExec([]string{"sqlite3", "/tmp/stalwart/data.sqlite", "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');"}).
Directory("/tmp/stalwart")
return dag.Container().
From("stalwartlabs/stalwart:v0.14.1").
WithFile("/etc/stalwart/config.toml.orig", config).
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
WithDirectory("/tmp/stalwart", dataDir).
WithExposedPort(8080). // JMAP
WithExposedPort(1430). // IMAP
WithExposedPort(1025). // SMTP
WithExposedPort(4190). // ManageSieve
WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}).
AsService()
}
// WithStalwart binds the Stalwart service and sets test environment variables.
func (m *Ci) WithStalwart(container *dagger.Container) *dagger.Container {
stalwart := m.Stalwart()
return container.
WithServiceBinding("stalwart", stalwart).
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
WithEnvVariable("STALWART_IMAP_PORT", "1430").
WithEnvVariable("STALWART_SMTP_PORT", "1025").
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
WithEnvVariable("STALWART_USER_B", "alice@example.com").
WithEnvVariable("STALWART_PASS_B", "secret").
WithEnvVariable("STALWART_USER_C", "bob@example.com").
WithEnvVariable("STALWART_PASS_C", "secret")
}
// CheckHygiene checks that no forbidden home-directory files are in the source.
func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
return m.Base().
WithDirectory("/src", m.Source, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c", "FORBIDDEN=\".ssh .bashrc .config .local .cache .gitconfig .android Android .gradle .pub-cache .dartServer .flutter .dart-cli-completion .atuin .bash_logout .profile .zcompdump .zshrc snap .emulator_console_auth_token .lesshst .metadata .tmux.conf\"; for f in $FORBIDDEN; do if [ -e \"$f\" ]; then echo \"ERROR: Forbidden file/dir found in source: $f\"; exit 1; fi; done; echo \"Hygiene check passed.\""}).
Stdout(ctx)
}
// CheckLayers enforces that ui/ does not import data/.
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
return m.Base().
WithDirectory("/src", m.Source.Filter(dagger.DirectoryFilterOpts{Include: []string{"lib/"}}), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c", "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; echo \"Layer check passed.\""}).
Stdout(ctx)
}
// Format runs dart format check.
func (m *Ci) Format(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).
Stdout(ctx)
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It snapshots the committed source (including any stale generated files) before
// running build_runner, so git diff detects real staleness instead of always
// comparing two freshly-generated outputs.
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
return m.pubGetLayer().
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
WithExec([]string{"git", "init"}).
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
WithExec([]string{"git", "config", "user.name", "CI"}).
WithExec([]string{"git", "add", "."}).
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
Stdout(ctx)
}
// Coverage runs unit and widget tests with coverage gate.
func (m *Ci) Coverage(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
Stdout(ctx)
}
// TestBackend runs IMAP/JMAP sync tests against a live Stalwart instance.
func (m *Ci) TestBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
// TestIntegration runs UI integration tests via Xvfb.
func (m *Ci) TestIntegration(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.integrationSrc())).
WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1").
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`xvfb-run -s '-screen 0 1280x720x24' flutter test integration_test/ -d linux >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
// TestSyncReliability runs the sync reliability runner.
func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/backend/sync_reliability_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
// Check runs the full check suite.
func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
// Run cheap structural checks in parallel for faster fail detection.
var fastEg errgroup.Group
fastEg.Go(func() error {
_, err := m.CheckHygiene(ctx)
return err
})
fastEg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
if err := fastEg.Wait(); err != nil {
return "", err
}
checkSetup := m.setup(m.checkSrc())
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
return "Format check failed", err
}
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
if err != nil {
return analyze, err
}
mocks, err := m.CheckGenerated(ctx)
if err != nil {
return mocks, err
}
coverage, err := m.Coverage(ctx)
if err != nil {
return coverage, err
}
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
// sibling via context — which would surface as "context canceled" in dagger
// output and trigger spurious retries in check-dagger.
var testBackend, testIntegration string
var eg errgroup.Group
eg.Go(func() error {
var e error
testBackend, e = m.TestBackend(ctx)
return e
})
eg.Go(func() error {
var e error
testIntegration, e = m.TestIntegration(ctx)
return e
})
if err := eg.Wait(); err != nil {
return "", err
}
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, mocks, coverage, testBackend, testIntegration), nil
}
// GenerateBuildHistory scans the remote server and produces Hugo content.
func (m *Ci) GenerateBuildHistory(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
) *dagger.Directory {
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"scripts/generate_build_history.py", "website/"},
})
return dag.Container().
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("SSH_USER", sshUser).
WithEnvVariable("SSH_HOST", sshHost).
WithDirectory("/src", scriptSource).
WithWorkdir("/src").
WithExec([]string{"/bin/sh", "-c", "python3 scripts/generate_build_history.py"}).
Directory("website/content/builds")
}
// BuildWebsite builds the Hugo-based website.
func (m *Ci) BuildWebsite(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
// +optional
commitHash string,
) *dagger.Directory {
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"website/"},
}).WithDirectory("website/content/builds", buildHistory)
hugo := m.Hugo().
WithDirectory("/src", websiteSource).
WithWorkdir("/src/website")
if commitHash != "" {
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
}
return hugo.
WithExec([]string{"hugo", "--minify"}).
Directory("public")
}
// PublishWebsite builds and deploys the website to the remote server.
func (m *Ci) PublishWebsite(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
// +optional
commitHash string,
) (string, error) {
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
return m.Deployer(sshKey, knownHosts).
WithDirectory("/public", public).
WithExec([]string{"rsync", "-avz", "--delete",
"--exclude=*.apk", "--exclude=*.tar.gz",
"/public/", fmt.Sprintf("%s@%s:public_html/", sshUser, sshHost)}).
Stdout(ctx)
}
// BuildLinux builds the Linux release bundle.
func (m *Ci) BuildLinux() *dagger.Directory {
return m.setup(m.linuxSrc()).
WithExec([]string{"flutter", "build", "linux", "--release"}).
Directory("build/linux/x64/release/bundle")
}
// BuildLinuxRelease builds the Linux release bundle.
func (m *Ci) BuildLinuxRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.Directory {
args := []string{"flutter", "build", "linux", "--release"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setup(m.linuxSrc()).
WithExec(args).
Directory("build/linux/x64/release/bundle")
}
// DeployLinux packages and deploys the Linux release to the server.
func (m *Ci) DeployLinux(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
commitHash string,
) (string, error) {
bundle := m.BuildLinuxRelease(commitHash)
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
return m.Deployer(sshKey, knownHosts).
WithDirectory("/bundle", bundle).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
Stdout(ctx)
}
// setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
}
// BuildAndroidApk builds a release APK signed with the upload key.
func (m *Ci) BuildAndroidApk(
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec(args).
File("build/app/outputs/flutter-apk/app-release.apk")
}
// DeployApk builds and deploys the APK to the server.
func (m *Ci) DeployApk(
ctx context.Context,
sshKey *dagger.Secret,
knownHosts *dagger.Secret,
sshUser string,
sshHost string,
commitHash string,
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
return m.Deployer(sshKey, knownHosts).
WithFile("/tmp/app.apk", apk).
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
Stdout(ctx)
}
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.firebaseBase().
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was
// preserved in the Dagger layer snapshot but whose process no longer exists.
WithExec([]string{"./gradlew", "--no-daemon", "app:assembleAndroidTest"}).
WithWorkdir("/src").
WithExec([]string{"/bin/bash", "-c",
`apk=$(find /src -path "*androidTest*" -name "*.apk" -type f 2>/dev/null | head -1) && \
[ -n "$apk" ] || { echo "ERROR: no androidTest APK found; APKs present:"; find /src -name "*.apk" -type f 2>/dev/null; exit 1; } && \
echo "Found test APK: $apk" && \
cp "$apk" /src/app-debug-androidTest.apk`})
return dag.Directory().
WithFile("app-debug.apk",
built.File("build/app/outputs/flutter-apk/app-debug.apk")).
WithFile("app-debug-androidTest.apk",
built.File("app-debug-androidTest.apk"))
}
// TestAndroidFirebase builds Android APKs and runs instrumented tests on Firebase Test Lab.
func (m *Ci) TestAndroidFirebase(
ctx context.Context,
serviceAccountKey *dagger.Secret,
projectID string,
) (string, error) {
apks := m.BuildAndroidDebugApks()
return dag.Container().
From("google/cloud-sdk:slim").
WithDirectory("/apks", apks).
WithSecretVariable("FIREBASE_SA_KEY", serviceAccountKey).
WithEnvVariable("FIREBASE_PROJECT_ID", projectID).
WithExec([]string{"/bin/bash", "-c",
`auth_err=$(mktemp); trap 'rm -f "$auth_err"' EXIT; \
gcloud auth activate-service-account --key-file=<(echo "$FIREBASE_SA_KEY") 2>"$auth_err" \
|| { cat "$auth_err"; exit 1; }; \
gcloud config set project "$FIREBASE_PROJECT_ID" 2>>"$auth_err" \
|| { cat "$auth_err"; exit 1; }; \
unknown=$(grep -vF "Activated service account credentials for:" "$auth_err" \
| grep -vF "Updated property [core/project]." | grep -v "^$" || true); \
[ -z "$unknown" ] || { echo "ERROR: unexpected gcloud auth output: $unknown"; exit 1; }; \
out=$(gcloud firebase test android run \
--type instrumentation \
--app /apks/app-debug.apk \
--test /apks/app-debug-androidTest.apk \
--device model=oriole,version=33,locale=en,orientation=portrait \
--results-bucket=gs://sharedinbox-ftl-results 2>&1); rc=$?; echo "$out"; \
[ "$rc" -eq 0 ] || { echo "ERROR: gcloud firebase test exited with code $rc"; exit "$rc"; }; \
expected_devices=1; \
actual_devices=$(echo "$out" | grep "│" | grep -cE "(Passed|Failed|Inconclusive|Skipped)") || actual_devices=0; \
[ "$actual_devices" -eq "$expected_devices" ] || \
{ echo "ERROR: expected $expected_devices test result(s) but found $actual_devices"; exit 1; }; \
echo "$out" | grep -q "Passed" || { echo "ERROR: no passing test results — tests failed or did not run"; exit 1; }`}).
Stdout(ctx)
}
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
File("build/app/outputs/bundle/release/app-release.aab")
}
// withGoCache mounts Dagger cache volumes for GOCACHE and GOMODCACHE so Go
// builds inside the container reuse cached packages between pipeline runs.
func withGoCache(c *dagger.Container) *dagger.Container {
return c.
WithMountedCache("/home/ci/.cache/go-build", dag.CacheVolume("go-build-cache")).
WithMountedCache("/home/ci/go/pkg/mod", dag.CacheVolume("go-mod-cache")).
WithEnvVariable("GOCACHE", "/home/ci/.cache/go-build").
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
}
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
func (m *Ci) UploadToPlayStore(
ctx context.Context,
aab *dagger.File,
playStoreConfig *dagger.Secret,
) (string, error) {
scriptSource := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"scripts/deploy_playstore.py"},
})
return dag.Container().
From("python:3.12-alpine").
WithExec([]string{"apk", "add", "--no-cache", "curl"}).
WithMountedCache("/root/.cache/pip", dag.CacheVolume("pip-cache")).
WithExec([]string{"pip", "install", "google-auth", "requests"}).
WithFile("/src/build/app/outputs/bundle/release/app-release.aab", aab).
WithFile("/src/scripts/deploy_playstore.py", scriptSource.File("scripts/deploy_playstore.py")).
WithSecretVariable("PLAY_STORE_CONFIG_JSON", playStoreConfig).
WithWorkdir("/src").
WithExec([]string{"python3", "scripts/deploy_playstore.py"}).
Stdout(ctx)
}
// StampAndroidVersionCode patches the versionCode in a built AAB without rebuilding.
func (m *Ci) StampAndroidVersionCode(aab *dagger.File, versionCode int) *dagger.File {
return dag.Container().
From("python:3.12-alpine").
WithNewFile("/patch.py", patchAabScript).
WithFile("/in.aab", aab).
WithExec([]string{"python3", "/patch.py", "/in.aab", "/out.aab", fmt.Sprintf("%d", versionCode)}).
File("/out.aab")
}
// SignAndroidBundle signs an AAB with the release upload key via jarsigner.
func (m *Ci) SignAndroidBundle(aab *dagger.File, keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.File {
return dag.Container().
From("eclipse-temurin:17-jdk-alpine").
WithFile("/app.aab", aab).
WithSecretVariable("KS_BASE64", keystoreBase64).
WithSecretVariable("KS_PASS", keystorePassword).
WithExec([]string{"sh", "-c",
`[ -n "$KS_BASE64" ] || { echo "ERROR: KS_BASE64 secret is empty — ANDROID_KEYSTORE_BASE64 not set"; exit 1; }
[ -n "$KS_PASS" ] || { echo "ERROR: KS_PASS secret is empty — ANDROID_KEYSTORE_PASSWORD not set"; exit 1; }
echo "$KS_BASE64" | base64 -d > /keystore.jks && \
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \
-signedjar /signed.aab \
-keystore /keystore.jks \
-storepass "$KS_PASS" -keypass "$KS_PASS" \
/app.aab upload`}).
File("/signed.aab")
}
// PublishAndroid builds a cached AAB, stamps the versionCode, re-signs, and uploads to Play Store.
func (m *Ci) PublishAndroid(
ctx context.Context,
playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) (string, error) {
versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease(commitHash)
stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
}
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
return dag.Container().
From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
// Graph returns a Mermaid diagram of the CI pipeline structure.
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
// or save it as a .md file to get a rendered diagram.
//
// Usage:
//
// dagger call --progress=plain -q -m ci --source=. graph
func (m *Ci) Graph() string {
return fmt.Sprintf(`# CI Pipeline Graph
`+"```"+`mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
toolchain --> pubGet
pubGet --> codegen
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
codegen --> fmt["Format"]
codegen --> analyze["Analyze"]
codegen --> coverage["Coverage\nunit tests + gate"]
codegen --> backend["TestBackend\nIMAP / JMAP"]
codegen --> integration["TestIntegration\nXvfb · Linux desktop"]
stalwart --> backend
stalwart --> integration
hygiene --> check{{"✓ Check"}}
layers --> check
fmt --> check
analyze --> check
mocks --> check
coverage --> check
backend --> check
integration --> check
end
subgraph forgejo_ci ["Codeberg CI · ci.yml (push/PR, source paths only)"]
ciCheck["check"]
end
subgraph forgejo_deploy ["Codeberg CI · deploy.yml (hourly schedule + workflow_dispatch)"]
detectChanges["check-changes\ndetect android / linux diff"]
buildLinux["build-linux\n(linux changed)"]
deployPS["deploy-playstore\n(android changed)"]
deployApk["deploy-apk\n(android changed)"]
fbTest["test-android-firebase\n(android changed)"]
pubWeb["publish-website\n(any build succeeded)"]
detectChanges --> buildLinux
detectChanges --> deployPS
detectChanges --> deployApk
detectChanges --> fbTest
buildLinux --> pubWeb
deployPS --> pubWeb
deployApk --> pubWeb
end
check -- "task check-dagger" --> ciCheck
` + "```"
}
-195
View File
@@ -1,195 +0,0 @@
#!/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()
-45
View File
@@ -3,48 +3,3 @@
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.
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
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"
-55
View File
@@ -1,55 +0,0 @@
#!/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()
-22
View File
@@ -4,28 +4,6 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26)
- **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
Generated
+3 -24
View File
@@ -1,25 +1,5 @@
{ {
"nodes": { "nodes": {
"dagger": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778107833,
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
"owner": "dagger",
"repo": "nix",
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
"type": "github"
},
"original": {
"owner": "dagger",
"repo": "nix",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -40,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1778737229, "lastModified": 1778430510,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=", "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5", "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -56,7 +36,6 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"dagger": "dagger",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
+3 -17
View File
@@ -4,14 +4,12 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
dagger.url = "github:dagger/nix";
dagger.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, flake-utils, dagger }: outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = import nixpkgs { inherit system; };
# All Linux desktop runtime libraries needed by flutter build linux and # 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 # the UI integration tests (xvfb-run). Kept as a list so we can reuse
@@ -29,11 +27,7 @@
cairo cairo
gdk-pixbuf gdk-pixbuf
harfbuzz harfbuzz
# Dagger remote setup dependencies
stunnel
netcat
]; ];
fgj = pkgs.stdenv.mkDerivation { fgj = pkgs.stdenv.mkDerivation {
pname = "fgj"; pname = "fgj";
version = "0.4.0"; version = "0.4.0";
@@ -51,13 +45,8 @@
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Dagger CLI
dagger.packages.${system}.dagger
# Go compiler — for Dagger development
go
# Java JDK — required by Gradle for Android builds # Java JDK — required by Gradle for Android builds
jdk17
# Task runner # Task runner
go-task go-task
@@ -95,11 +84,8 @@
# python3 base + Google Play API client (for scripts/deploy_playstore.py) # python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [ (python3.withPackages (ps: with ps; [
google-api-python-client google-api-python-client
google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py ])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub) fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
]); ]);
shellHook = '' shellHook = ''
-25
View File
@@ -1,25 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+20 -70
View File
@@ -112,28 +112,12 @@ void main() {
late String userPass; late String userPass;
setUpAll(() { setUpAll(() {
const required = [ imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
'STALWART_IMAP_HOST', imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
'STALWART_IMAP_PORT', smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
'STALWART_SMTP_HOST', smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
'STALWART_SMTP_PORT', userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
'STALWART_USER_B', userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
'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(
@@ -146,12 +130,17 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
// Capture the test binding's error recorder and error-widget builder // On Android, the keyboard-dismiss / window-resize cycle can trigger
// BEFORE app.main() so teardown can restore both. app.main() overwrites // one final layout pass on already-disposed render objects (DEFUNCT).
// FlutterError.onError (crash-screen handler) and ErrorWidget.builder; // These spurious overflow errors have no effect on real functionality;
// the test binding verifies both are unchanged after the test completes. // filter them so they don't fail the test.
final bindingError = FlutterError.onError; final prevError = FlutterError.onError;
final bindingErrorWidgetBuilder = ErrorWidget.builder; FlutterError.onError = (details) {
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(
@@ -166,36 +155,7 @@ 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 ────────────────────────────────────────────────────────
@@ -288,12 +248,6 @@ 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,
@@ -303,10 +257,6 @@ 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.
@@ -317,7 +267,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 folders')); await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Sent')); await tester.tap(find.text('Sent'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -331,7 +281,7 @@ void main() {
expect(find.text(subject), findsOneWidget); expect(find.text(subject), findsOneWidget);
// ── Check Inbox ──────────────────────────────────────────────────────── // ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open folders')); await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
-1
View File
@@ -1 +0,0 @@
const int dbSchemaVersion = 38;
-2
View File
@@ -7,7 +7,6 @@ class SavedDraft {
final String subjectText; final String subjectText;
final String bodyText; final String bodyText;
final DateTime updatedAt; final DateTime updatedAt;
final String? imapServerId;
const SavedDraft({ const SavedDraft({
required this.id, required this.id,
@@ -18,6 +17,5 @@ class SavedDraft {
required this.subjectText, required this.subjectText,
required this.bodyText, required this.bodyText,
required this.updatedAt, required this.updatedAt,
this.imapServerId,
}); });
} }
-26
View File
@@ -21,8 +21,6 @@ class Email {
final String? references; final String? references;
final DateTime? snoozedUntil; final DateTime? snoozedUntil;
final String? snoozedFromMailboxPath; final String? snoozedFromMailboxPath;
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
final String? listUnsubscribeHeader;
const Email({ const Email({
required this.id, required this.id,
@@ -45,7 +43,6 @@ class Email {
this.references, this.references,
this.snoozedUntil, this.snoozedUntil,
this.snoozedFromMailboxPath, this.snoozedFromMailboxPath,
this.listUnsubscribeHeader,
}); });
factory Email.fromJson(Map<String, dynamic> json) { factory Email.fromJson(Map<String, dynamic> json) {
@@ -80,7 +77,6 @@ class Email {
? DateTime.parse(json['snoozedUntil'] as String) ? DateTime.parse(json['snoozedUntil'] as String)
: null, : null,
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?, snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
); );
} }
@@ -106,7 +102,6 @@ class Email {
'references': references, 'references': references,
'snoozedUntil': snoozedUntil?.toIso8601String(), 'snoozedUntil': snoozedUntil?.toIso8601String(),
'snoozedFromMailboxPath': snoozedFromMailboxPath, 'snoozedFromMailboxPath': snoozedFromMailboxPath,
'listUnsubscribeHeader': listUnsubscribeHeader,
}; };
} }
@@ -131,7 +126,6 @@ class Email {
String? references, String? references,
DateTime? snoozedUntil, DateTime? snoozedUntil,
String? snoozedFromMailboxPath, String? snoozedFromMailboxPath,
String? listUnsubscribeHeader,
}) { }) {
return Email( return Email(
id: id ?? this.id, id: id ?? this.id,
@@ -155,8 +149,6 @@ class Email {
snoozedUntil: snoozedUntil ?? this.snoozedUntil, snoozedUntil: snoozedUntil ?? this.snoozedUntil,
snoozedFromMailboxPath: snoozedFromMailboxPath:
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath, snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
listUnsubscribeHeader:
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
); );
} }
} }
@@ -232,29 +224,12 @@ 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,
@@ -262,7 +237,6 @@ class EmailBody {
this.htmlBody, this.htmlBody,
required this.attachments, required this.attachments,
this.headers = const [], this.headers = const [],
this.mimeTree,
}); });
} }
-31
View File
@@ -1,31 +0,0 @@
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;
}
@@ -21,10 +21,4 @@ abstract class DraftRepository {
/// Permanently removes the draft with [id]. /// Permanently removes the draft with [id].
Future<void> deleteDraft(int id); Future<void> deleteDraft(int id);
/// Syncs local drafts with the server IMAP Drafts folder for [accountId].
/// Uploads local drafts that have no [SavedDraft.imapServerId]; imports
/// server drafts that are not already tracked locally.
/// No-op when the implementation has no IMAP connection configured.
Future<void> syncDrafts(String accountId, String password);
} }
+3 -36
View File
@@ -1,23 +1,14 @@
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository { abstract class EmailRepository {
Stream<List<Email>> observeEmails( Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
String accountId,
String mailboxPath, {
int limit = 50,
});
/// Groups emails by threadId and returns one [EmailThread] per thread, /// Groups emails by threadId and returns one [EmailThread] per thread,
/// sorted by the latest message date descending. /// sorted by the latest message date descending.
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
String accountId, String accountId,
String mailboxPath, { String mailboxPath,
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(
@@ -31,7 +22,6 @@ abstract class EmailRepository {
Future<EmailBody> getEmailBody(String emailId); Future<EmailBody> getEmailBody(String emailId);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath); Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}); Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
Future<void> markAllAsRead(String accountId, String mailboxPath);
Future<void> moveEmail(String emailId, String destMailboxPath); Future<void> moveEmail(String emailId, String destMailboxPath);
/// Deletes the email. Returns the path of the mailbox it was moved to /// Deletes the email. Returns the path of the mailbox it was moved to
@@ -45,10 +35,6 @@ 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(
@@ -65,14 +51,6 @@ abstract class EmailRepository {
/// 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);
@@ -103,17 +81,6 @@ 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,13 +11,4 @@ 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,
);
} }
@@ -1,5 +0,0 @@
abstract interface class SearchHistoryRepository {
Future<List<String>> getRecentSearches();
Future<void> saveSearch(String query);
Future<void> clearHistory();
}
@@ -1,13 +0,0 @@
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,14 +4,12 @@ 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 {
@@ -19,8 +17,6 @@ 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,
@@ -36,8 +32,6 @@ 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;
@@ -58,8 +52,6 @@ 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,
@@ -87,8 +79,6 @@ 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,
@@ -1,14 +0,0 @@
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);
}
-82
View File
@@ -1,82 +0,0 @@
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,47 +0,0 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
const _kChannelId = 'new_mail';
const _kChannelName = 'New mail';
final _plugin = FlutterLocalNotificationsPlugin();
bool _initialized = false;
Future<void> initNotifications() async {
try {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
settings: const InitializationSettings(android: android),
onDidReceiveNotificationResponse: (_) {},
);
await _plugin
.resolvePlatformSpecificImplementation<
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 {
if (!Platform.isAndroid || !_initialized) return;
await _plugin.show(
id: accountEmail.hashCode & 0x7FFFFFFF,
title: 'New mail',
body: accountEmail,
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
_kChannelId,
_kChannelName,
channelDescription: 'Notifications for new incoming mail',
importance: Importance.high,
priority: Priority.high,
),
),
);
}
@@ -1,296 +0,0 @@
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();
}
}
+27 -61
View File
@@ -4,39 +4,38 @@ 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 Notifier<List<UndoAction>> { class UndoService extends StateNotifier<List<UndoAction>> {
UndoService(this._ref) : super([]);
final Ref _ref;
static const int _maxHistory = 10; static const int _maxHistory = 10;
// Resolves once build() has loaded persisted history. // Resolves once init() has loaded persisted history. Default to an already-
late Future<void> _ready; // resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
@override Future<void> init() async {
List<UndoAction> build() { _ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) { if (mounted) state = history;
if (ref.mounted) state = history;
}); });
return []; await _ready;
} }
/// 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);
await ref.read(undoRepositoryProvider).deleteAction(removed.id); unawaited(_ref.read(undoRepositoryProvider).deleteAction(removed.id));
} }
state = newList; state = newList;
await ref.read(undoRepositoryProvider).saveAction(action); unawaited(_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 {
@@ -46,19 +45,19 @@ class UndoService extends Notifier<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
} }
} }
// Keep the original entry in state and DB so the user can see what unawaited(_ref.read(undoRepositoryProvider).deleteAction(action.id));
// 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).
@@ -71,22 +70,10 @@ class UndoService extends Notifier<List<UndoAction>> {
? null ? null
: action.originalEmails.where((e) => e.id == id).firstOrNull; : action.originalEmails.where((e) => e.id == id).firstOrNull;
// 2. Resolve the current DB row for the email. // 2. If row is missing (hard delete), restore it first.
// For IMAP, after a server-applied move the email gets a new UID, so // We restore it at its CURRENT state (where it is on the server,
// the original id ('accountId:oldUid') no longer exists. Look it up by // or where it was moving to).
// Message-ID so we use the correct UID in the pending change. if (original != null) {
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);
@@ -95,40 +82,19 @@ class UndoService extends Notifier<List<UndoAction>> {
]); ]);
} }
// 4. Move it back to source. // 3. 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 using the correct UID. // a reverse move on the server.
await repo.moveEmail(currentId, action.sourceMailboxPath); await repo.moveEmail(id, action.sourceMailboxPath);
if (cancelled) { if (cancelled) {
// 5. If we successfully cancelled the original, the reverse move // 4. If we successfully cancelled the original, the reverse move
// we just enqueued is redundant. // we just enqueued is redundant.
await repo.cancelPendingChange(currentId, 'move'); await repo.cancelPendingChange(id, '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(),
),
);
}
} }
} }
-42
View File
@@ -1,42 +0,0 @@
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;
}
});
-17
View File
@@ -1,17 +0,0 @@
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;
}
-14
View File
@@ -1,14 +0,0 @@
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;
}
-136
View File
@@ -1,136 +0,0 @@
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
import 'package:sharedinbox/core/sieve/sieve_rule.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,
};
}
bool _globMatch(String value, String pattern) {
final regexStr = RegExp.escape(
pattern,
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^$regexStr\$').hasMatch(value);
}
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);
}
}
}
}
-587
View File
@@ -1,587 +0,0 @@
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;
}
}
-21
View File
@@ -1,21 +0,0 @@
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;
}
+25 -127
View File
@@ -1,20 +1,15 @@
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';
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/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart' import 'package:sharedinbox/data/imap/imap_client_factory.dart'
show ImapConnectFn, connectImap, verboseLogKey; show ImapConnectFn, connectImap, verboseLogKey;
import 'package:sharedinbox/data/imap/tls_error.dart' show isTlsConfigError;
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
/// Manages background sync for all accounts. /// Manages background sync for all accounts.
/// ///
@@ -27,35 +22,19 @@ class AccountSyncManager {
this._emails, { this._emails, {
ImapConnectFn imapConnect = connectImap, ImapConnectFn imapConnect = connectImap,
SyncLogRepository syncLog = const NoOpSyncLogRepository(), SyncLogRepository syncLog = const NoOpSyncLogRepository(),
DraftRepository? drafts,
OnNewMailCallback? onNewMail,
}) : _imapConnect = imapConnect, }) : _imapConnect = imapConnect,
_syncLog = syncLog, _syncLog = syncLog;
_drafts = drafts,
_onNewMail = onNewMail;
final AccountRepository _accounts; final AccountRepository _accounts;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final ImapConnectFn _imapConnect; final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
final Map<String, _SyncLoop> _active = {}; final Map<String, _SyncLoop> _active = {};
StreamSubscription<List<Account>>? _accountsSub; StreamSubscription<List<Account>>? _accountsSub;
StreamSubscription<String>? _onChangesSub; StreamSubscription<String>? _onChangesSub;
final _syncPhaseCtrl = StreamController<(String, bool)>.broadcast();
/// Emits `true` when [accountId] starts syncing, `false` when it stops.
Stream<bool> watchSyncing(String accountId) =>
_syncPhaseCtrl.stream.where((e) => e.$1 == accountId).map((e) => e.$2);
void _emitSyncing(String accountId, {required bool syncing}) {
if (!_syncPhaseCtrl.isClosed) _syncPhaseCtrl.add((accountId, syncing));
}
void start() { void start() {
_onChangesSub = _emails.onChangesQueued.listen((accountId) { _onChangesSub = _emails.onChangesQueued.listen((accountId) {
_active[accountId]?.kick(); _active[accountId]?.kick();
@@ -66,7 +45,6 @@ class AccountSyncManager {
for (final account in accounts) { for (final account in accounts) {
if (_active.containsKey(account.id)) continue; if (_active.containsKey(account.id)) continue;
final id = account.id;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
@@ -75,10 +53,6 @@ class AccountSyncManager {
_emails, _emails,
_imapConnect, _imapConnect,
_syncLog, _syncLog,
_drafts,
_onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -86,8 +60,6 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
}; };
_active[account.id] = loop; _active[account.id] = loop;
@@ -109,7 +81,6 @@ class AccountSyncManager {
s.stop(); s.stop();
} }
_active.clear(); _active.clear();
unawaited(_syncPhaseCtrl.close());
} }
/// Wakes the idle/wait phase of the given account's sync loop so a new /// Wakes the idle/wait phase of the given account's sync loop so a new
@@ -142,10 +113,6 @@ class AccountSyncManager {
_emails, _emails,
_imapConnect, _imapConnect,
_syncLog, _syncLog,
_drafts,
_onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -153,8 +120,6 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
}; };
_active[accountId] = loop; _active[accountId] = loop;
@@ -180,12 +145,7 @@ class _AccountSync implements _SyncLoop {
this._emails, this._emails,
this._imapConnect, this._imapConnect,
this._syncLog, this._syncLog,
this._drafts, );
this._onNewMail, {
void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -193,16 +153,11 @@ class _AccountSync implements _SyncLoop {
final EmailRepository _emails; final EmailRepository _emails;
final ImapConnectFn _imapConnect; final ImapConnectFn _imapConnect;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
imap.ImapClient? _idleClient; imap.ImapClient? _idleClient;
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() {
@@ -230,7 +185,6 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -250,18 +204,14 @@ class _AccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _idle(); await _idle();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
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,
@@ -296,8 +246,6 @@ class _AccountSync implements _SyncLoop {
} }
bool _isPermanentError(Object e) { bool _isPermanentError(Object e) {
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') ||
@@ -308,16 +256,11 @@ 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>();
_waitTimer = Timer(Duration(seconds: seconds), () { await Future.any([
if (!_stopSignal!.isCompleted) _stopSignal!.complete(); Future.delayed(Duration(seconds: seconds)),
}); _stopSignal!.future,
try { ]);
await _stopSignal!.future; _stopSignal = null;
} finally {
_waitTimer?.cancel();
_waitTimer = null;
_stopSignal = null;
}
} }
Future<(_SyncStats, String?)> _runSync(bool verbose) async { Future<(_SyncStats, String?)> _runSync(bool verbose) async {
@@ -336,8 +279,6 @@ class _AccountSync implements _SyncLoop {
Future<_SyncStats> _sync() async { Future<_SyncStats> _sync() async {
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
await _drafts?.syncDrafts(account.id, password);
// Check for expired snoozes and move them back to Inbox before syncing. // Check for expired snoozes and move them back to Inbox before syncing.
await _emails.wakeUpEmails(account.id); await _emails.wakeUpEmails(account.id);
@@ -351,7 +292,6 @@ 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(
@@ -360,11 +300,9 @@ 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,
@@ -387,7 +325,6 @@ class _AccountSync implements _SyncLoop {
await client.selectMailboxByPath('INBOX'); await client.selectMailboxByPath('INBOX');
final newMessageCompleter = Completer<void>(); final newMessageCompleter = Completer<void>();
var hasNewMail = false;
final sub = client.eventBus final sub = client.eventBus
.on<imap.ImapEvent>() .on<imap.ImapEvent>()
@@ -395,11 +332,7 @@ class _AccountSync implements _SyncLoop {
(e) => (e) =>
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent, e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
) )
.listen((e) { .listen((_) {
if (e is imap.ImapMessagesExistEvent &&
e.newMessagesExists > e.oldMessagesExists) {
hasNewMail = true;
}
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete(); if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
}); });
@@ -407,23 +340,14 @@ 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.
final idleTimer = Timer(const Duration(minutes: 25), () { await Future.any([
if (_stopSignal != null && !_stopSignal!.isCompleted) { newMessageCompleter.future,
_stopSignal!.complete(); Future.delayed(const Duration(minutes: 25)),
} _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();
if (hasNewMail) {
unawaited(_onNewMail?.call(account.email));
}
} finally { } finally {
await client.logout(); await client.logout();
_idleClient = null; _idleClient = null;
@@ -440,24 +364,18 @@ class _JmapAccountSync implements _SyncLoop {
this._mailboxes, this._mailboxes,
this._emails, this._emails,
this._accounts, this._accounts,
this._syncLog, { this._syncLog,
void Function()? onSyncStart, );
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final AccountRepository _accounts; final AccountRepository _accounts;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
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);
@@ -485,7 +403,6 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -505,18 +422,14 @@ class _JmapAccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _wait(); await _wait();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
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,
@@ -551,8 +464,6 @@ class _JmapAccountSync implements _SyncLoop {
} }
bool _isPermanentError(Object e) { bool _isPermanentError(Object e) {
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') ||
@@ -564,16 +475,11 @@ 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>();
_waitTimer = Timer(Duration(seconds: seconds), () { await Future.any([
if (!_stopSignal!.isCompleted) _stopSignal!.complete(); Future.delayed(Duration(seconds: seconds)),
}); _stopSignal!.future,
try { ]);
await _stopSignal!.future; _stopSignal = null;
} finally {
_waitTimer?.cancel();
_waitTimer = null;
_stopSignal = null;
}
} }
Future<(_SyncStats, String?)> _runSync(bool verbose) async { Future<(_SyncStats, String?)> _runSync(bool verbose) async {
@@ -608,7 +514,6 @@ 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(
@@ -617,11 +522,9 @@ 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,
@@ -648,16 +551,11 @@ class _JmapAccountSync implements _SyncLoop {
onError: (_) {}, onError: (_) {},
); );
final pollTimer = Timer(_pollInterval, () { await Future.any([
if (_stopSignal != null && !_stopSignal!.isCompleted) { pushReady.future,
_stopSignal!.complete(); Future.delayed(_pollInterval),
} _stopSignal!.future,
}); ]);
try {
await Future.any([pushReady.future, _stopSignal!.future]);
} finally {
pollTimer.cancel();
}
await pushSub.cancel(); await pushSub.cancel();
_stopSignal = null; _stopSignal = null;
-184
View File
@@ -1,184 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
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_provider/path_provider.dart';
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/services/body_cache_service.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
import 'package:workmanager/workmanager.dart';
const _kTaskName = 'si_bg_sync';
const _kPrefetchTaskName = 'si_bg_prefetch';
const _kResourceType = 'background_check';
@pragma('vm:entry-point')
void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((taskName, __) async {
try {
if (taskName == _kPrefetchTaskName) {
await _doBodyPrefetch();
} else {
await _doBackgroundSync();
}
} catch (_) {}
return true;
});
}
Future<void> registerBackgroundSync() async {
try {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
_kTaskName,
_kTaskName,
frequency: const Duration(minutes: 15),
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 {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
);
try {
final accountRepo = AccountRepositoryImpl(
db,
const FlutterSecureStorageImpl(),
);
final accounts = await accountRepo.observeAccounts().first;
await initNotifications();
for (final account in accounts) {
if (account.type != model.AccountType.imap) continue;
await _checkAccount(db, accountRepo, account);
}
} finally {
await db.close();
}
}
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(
AppDatabase db,
AccountRepository accountRepo,
model.Account account,
) async {
try {
final password = await accountRepo.getPassword(account.id);
final username =
account.username.isNotEmpty ? account.username : account.email;
final client = await connectImap(account, username, password);
try {
final status = await client.statusMailbox(
imap.Mailbox.virtual('INBOX', []),
[imap.StatusFlags.uidNext],
);
final currentUidNext = status.uidNext;
final stored = await (db.select(db.syncStates)
..where(
(t) =>
t.accountId.equals(account.id) &
t.resourceType.equals(_kResourceType),
))
.getSingleOrNull();
final lastUidNext = _parseUidNext(stored?.state);
await db.into(db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: account.id,
resourceType: _kResourceType,
state: jsonEncode({'uidNext': currentUidNext}),
syncedAt: DateTime.now(),
),
);
if (lastUidNext != null &&
currentUidNext != null &&
currentUidNext > lastUidNext) {
await showNewMailNotification(account.email);
}
} finally {
await client.logout();
}
} catch (_) {}
}
int? _parseUidNext(String? state) {
if (state == null) return null;
try {
final decoded = jsonDecode(state);
if (decoded is Map<String, Object?>) {
return decoded['uidNext'] as int?;
}
return null;
} catch (_) {
return null;
}
}
+3 -10
View File
@@ -50,7 +50,7 @@ class ReliabilityRunner {
} }
} }
Future<void> _runForAccount(String accountId, {bool force = false}) async { Future<void> _runForAccount(String accountId) 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 (!force && !_running) break; if (!_running) break;
final result = await _emails.verifySyncReliability( final result = await _emails.verifySyncReliability(
accountId, accountId,
mailbox.path, mailbox.path,
@@ -103,14 +103,7 @@ 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 {
final accounts = await _accounts.observeAccounts().first; await _runAll();
for (final account in accounts) {
await _runForAccount(account.id, force: true);
}
} }
} }
-41
View File
@@ -1,41 +0,0 @@
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;
}
+10 -305
View File
@@ -3,10 +3,8 @@ 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';
part 'database.g.dart'; part 'database.g.dart';
@@ -90,9 +88,6 @@ class Emails extends Table {
DateTimeColumn get snoozedUntil => dateTime().nullable()(); DateTimeColumn get snoozedUntil => dateTime().nullable()();
TextColumn get snoozedFromMailboxPath => text().nullable()(); TextColumn get snoozedFromMailboxPath => text().nullable()();
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
TextColumn get listUnsubscribeHeader => text().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -109,8 +104,6 @@ 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};
@@ -193,9 +186,6 @@ 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.
@@ -209,8 +199,6 @@ 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.
@@ -239,44 +227,6 @@ class Drafts extends Table {
TextColumn get subjectText => text().withDefault(const Constant(''))(); TextColumn get subjectText => text().withDefault(const Constant(''))();
TextColumn get bodyText => text().withDefault(const Constant(''))(); TextColumn get bodyText => text().withDefault(const Constant(''))();
DateTimeColumn get updatedAt => dateTime()(); DateTimeColumn get updatedAt => dateTime()();
// Added in schema v24: IMAP UID string ("mailbox:uid") on the server.
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')
class SearchHistoryEntries extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get query => text()();
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')
@@ -292,55 +242,6 @@ 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};
}
/// 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(
@@ -357,59 +258,16 @@ class UserPreferences extends Table {
SyncLogMailboxes, SyncLogMailboxes,
SyncHealth, SyncHealth,
UndoActions, UndoActions,
SearchHistoryEntries,
LocalSieveScripts,
LocalSieveApplied,
ShareKeys,
UserPreferences,
ImageTrustedSenders,
], ],
) )
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 => dbSchemaVersion; int get schemaVersion => 22;
Future<void> _createEmailFts() async {
await customStatement('''
CREATE VIRTUAL TABLE IF NOT EXISTS email_fts USING fts5(
subject, preview, from_json,
content='emails',
content_rowid='rowid'
)
''');
await customStatement('''
CREATE TRIGGER IF NOT EXISTS email_fts_ai
AFTER INSERT ON emails BEGIN
INSERT INTO email_fts(rowid, subject, preview, from_json)
VALUES (new.rowid, new.subject, new.preview, new.from_json);
END
''');
await customStatement('''
CREATE TRIGGER IF NOT EXISTS email_fts_au
AFTER UPDATE OF subject, preview, from_json ON emails BEGIN
INSERT INTO email_fts(email_fts, rowid, subject, preview, from_json)
VALUES ('delete', old.rowid, old.subject, old.preview, old.from_json);
INSERT INTO email_fts(rowid, subject, preview, from_json)
VALUES (new.rowid, new.subject, new.preview, new.from_json);
END
''');
await customStatement('''
CREATE TRIGGER IF NOT EXISTS email_fts_ad
AFTER DELETE ON emails BEGIN
INSERT INTO email_fts(email_fts, rowid, subject, preview, from_json)
VALUES ('delete', old.rowid, old.subject, old.preview, old.from_json);
END
''');
}
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
await _createEmailFts();
},
onUpgrade: (m, from, to) async { onUpgrade: (m, from, to) async {
// NOTE: m.createTable(T) creates the LATEST version of table T. // NOTE: m.createTable(T) creates the LATEST version of table T.
// If you later add a column C to T in version X, you must guard // If you later add a column C to T in version X, you must guard
@@ -562,83 +420,6 @@ class AppDatabase extends _$AppDatabase {
), ),
); );
} }
if (from < 23) {
await m.addColumn(emails, emails.listUnsubscribeHeader);
}
if (from >= 4 && from < 24) {
await m.addColumn(drafts, drafts.imapServerId);
}
if (from < 25) {
// For observeMailboxes: filter by account_id, sort by path.
await m.createIndex(
Index(
'mailboxes_account_id',
'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);',
),
);
// For observeThreads: filter by account_id+mailbox_path, sort by latest_date.
await m.createIndex(
Index(
'threads_latest_date',
'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);',
),
);
}
if (from < 26) {
await _createEmailFts();
// Backfill FTS index from existing rows.
await customStatement('''
INSERT INTO email_fts(rowid, subject, preview, from_json)
SELECT rowid, subject, preview, from_json FROM emails
''');
}
if (from < 27) {
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,
);
}
}, },
); );
} }
@@ -648,96 +429,20 @@ 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 {
try { final dir = await getApplicationSupportDirectory();
final dir = await getApplicationSupportDirectory(); _dbPath = p.join(dir.path, 'sharedinbox.db');
_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();
LazyDatabase _openConnection() { LazyDatabase _openConnection() {
return LazyDatabase(() async { return LazyDatabase(() async {
final file = File(await _resolveDatabasePath()); final file = File(
_dbPath ??
p.join(
(await getApplicationSupportDirectory()).path,
'sharedinbox.db',
),
);
return NativeDatabase.createInBackground( return NativeDatabase.createInBackground(
file, file,
setup: (db) { setup: (db) {
-96
View File
@@ -1,96 +0,0 @@
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)));
});
}
}
+4 -41
View File
@@ -21,52 +21,15 @@ class TlsModeMismatchException implements Exception {
'STARTTLS). Original error: $original'; 'STARTTLS). Original error: $original';
} }
/// Wraps a TLS certificate verification failure into a user-actionable message. /// If [error] is a TLS handshake failure caused by a wrong-version-number
/// /// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
/// Thrown when the server's certificate cannot be verified — either because it /// with [host]/[port] context. Otherwise rethrow [error] unchanged.
/// is self-signed, expired, or the CA chain has changed since the account was
/// set up.
class TlsCertificateException implements Exception {
TlsCertificateException(this.host, this.port, this.original);
final String host;
final int port;
final Object original;
@override
String toString() =>
'TLS certificate error on $host:$port — the server certificate could '
'not be verified. The certificate may have changed or expired. '
'Please re-check your account settings or contact your mail provider. '
'Original error: $original';
}
/// Returns true if [error] is a permanent TLS configuration error that will
/// not resolve on its own and requires user action.
bool isTlsConfigError(Object error) =>
error is TlsModeMismatchException || error is TlsCertificateException;
/// If [error] is a recognisable TLS handshake failure, wraps it in a typed
/// exception and throws it. Otherwise rethrows [error] unchanged.
///
/// Recognised patterns:
/// - `WRONG_VERSION_NUMBER` → [TlsModeMismatchException] (port/mode mismatch)
/// - `CERTIFICATE_VERIFY_FAILED` / `HandshakeException` → [TlsCertificateException]
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) { Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
final s = error.toString(); if (error.toString().contains('WRONG_VERSION_NUMBER')) {
if (s.contains('WRONG_VERSION_NUMBER')) {
Error.throwWithStackTrace( Error.throwWithStackTrace(
TlsModeMismatchException(host, port, error), TlsModeMismatchException(host, port, error),
stack, stack,
); );
} }
if (s.contains('CERTIFICATE_VERIFY_FAILED') ||
s.contains('HandshakeException') ||
s.contains('CERTIFICATE_EXPIRED') ||
s.contains('CERTIFICATE_UNKNOWN')) {
Error.throwWithStackTrace(
TlsCertificateException(host, port, error),
stack,
);
}
Error.throwWithStackTrace(error, stack); Error.throwWithStackTrace(error, stack);
} }
@@ -1,20 +1,13 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/draft.dart'; import 'package:sharedinbox/core/models/draft.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/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
class DraftRepositoryImpl implements DraftRepository { class DraftRepositoryImpl implements DraftRepository {
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect}) DraftRepositoryImpl(this._db);
: _imapConnect = imapConnect;
final AppDatabase _db; final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn? _imapConnect;
@override @override
Future<SavedDraft> saveDraft({ Future<SavedDraft> saveDraft({
@@ -102,108 +95,6 @@ class DraftRepositoryImpl implements DraftRepository {
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go(); await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
} }
@override
Future<void> syncDrafts(String accountId, String password) async {
final connect = _imapConnect;
if (connect == null) return;
final account = await _accounts.getAccount(accountId);
if (account == null || account.type != AccountType.imap) return;
final username =
account.username.isNotEmpty ? account.username : account.email;
imap.ImapClient? client;
try {
client = await connect(account, username, password);
await _syncWithServer(client, accountId);
} finally {
await client?.logout();
}
}
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
// Create/select the Drafts folder.
try {
await client.createMailbox('Drafts');
} catch (_) {
// Already exists.
}
final selectResult = await client.selectMailboxByPath('Drafts');
final messageCount = selectResult.messagesExists;
// Upload local drafts that have no server counterpart.
final localDrafts = await (_db.select(_db.drafts)
..where(
(t) => t.accountId.equals(accountId) & t.imapServerId.isNull(),
))
.get();
for (final row in localDrafts) {
final builder = imap.MessageBuilder()
..to = _parseAddresses(row.toText)
..cc = _parseAddresses(row.ccText)
..subject = row.subjectText
..text = row.bodyText;
final mime = builder.buildMimeMessage();
final appendResult = await client.appendMessage(
mime,
targetMailboxPath: 'Drafts',
flags: [r'\Draft'],
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final uid = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: null;
if (uid != null) {
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
DraftsCompanion(imapServerId: Value(uid)),
);
}
}
// Download server drafts not tracked locally.
if (messageCount > 0) {
final knownServerIds = await (_db.select(_db.drafts)
..where(
(t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(),
))
.get();
final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet();
final seq = imap.MessageSequence.fromAll();
final fetch = await client.uidFetchMessages(seq, '(UID FLAGS ENVELOPE)');
for (final msg in fetch.messages) {
final uid = msg.uid?.toString();
if (uid == null || knownIds.contains(uid)) continue;
if (msg.flags?.contains(r'\Deleted') ?? false) continue;
final env = msg.envelope;
final now = DateTime.now();
await _db.into(_db.drafts).insert(
DraftsCompanion.insert(
accountId: Value(accountId),
toText: Value(_addressListToText(env?.to)),
ccText: Value(_addressListToText(env?.cc)),
subjectText: Value(env?.subject ?? ''),
bodyText: const Value(''),
updatedAt: now,
imapServerId: Value(uid),
),
);
}
}
}
List<imap.MailAddress> _parseAddresses(String text) {
if (text.trim().isEmpty) return [];
return text.split(',').map((s) => imap.MailAddress('', s.trim())).toList();
}
String _addressListToText(List<imap.MailAddress>? addresses) {
if (addresses == null || addresses.isEmpty) return '';
return addresses.map((a) => a.email).join(', ');
}
SavedDraft _toModel(Draft row) => SavedDraft( SavedDraft _toModel(Draft row) => SavedDraft(
id: row.id, id: row.id,
accountId: row.accountId, accountId: row.accountId,
@@ -213,6 +104,5 @@ class DraftRepositoryImpl implements DraftRepository {
subjectText: row.subjectText, subjectText: row.subjectText,
bodyText: row.bodyText, bodyText: row.bodyText,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
imapServerId: row.imapServerId,
); );
} }
+44 -637
View File
@@ -13,10 +13,6 @@ 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';
@@ -62,17 +58,15 @@ class EmailRepositoryImpl implements EmailRepository {
@override @override
Stream<List<model.Email>> observeEmails( Stream<List<model.Email>> observeEmails(
String accountId, String accountId,
String mailboxPath, { String mailboxPath,
int limit = 50, ) {
}) {
return (_db.select(_db.emails) return (_db.select(_db.emails)
..where( ..where(
(t) => (t) =>
t.accountId.equals(accountId) & t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath), t.mailboxPath.equals(mailboxPath),
) )
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]) ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
..limit(limit))
.watch() .watch()
.map((rows) => rows.map(_toModel).toList()); .map((rows) => rows.map(_toModel).toList());
} }
@@ -80,41 +74,19 @@ class EmailRepositoryImpl implements EmailRepository {
@override @override
Stream<List<model.EmailThread>> observeThreads( Stream<List<model.EmailThread>> observeThreads(
String accountId, String accountId,
String mailboxPath, { String mailboxPath,
int limit = 50, ) {
}) {
return (_db.select(_db.threads) return (_db.select(_db.threads)
..where( ..where(
(t) => (t) =>
t.accountId.equals(accountId) & t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath), t.mailboxPath.equals(mailboxPath),
) )
..orderBy([(t) => OrderingTerm.desc(t.latestDate)]) ..orderBy([(t) => OrderingTerm.desc(t.latestDate)]))
..limit(limit))
.watch() .watch()
.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>;
@@ -176,7 +148,6 @@ class EmailRepositoryImpl implements EmailRepository {
return; return;
} }
if (threadEmails.isEmpty) return;
final latest = threadEmails.last; final latest = threadEmails.last;
// Collect unique participants across the whole thread. // Collect unique participants across the whole thread.
@@ -258,16 +229,9 @@ class EmailRepositoryImpl implements EmailRepository {
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])'); final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
final msg = fetch.messages.firstOrNull; final msg = fetch.messages.first;
if (msg == null) {
throw StateError(
'IMAP server returned no message for UID ${emailRow.uid}.',
);
}
final textBody = msg.decodeTextPlainPart(); final textBody = msg.decodeTextPlainPart();
final rawHtml = msg.decodeTextHtmlPart(); final htmlBody = msg.decodeTextHtmlPart();
final htmlBody =
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
final contentInfos = msg.findContentInfo(); final contentInfos = msg.findContentInfo();
final attachmentsJson = jsonEncode( final attachmentsJson = jsonEncode(
@@ -291,8 +255,6 @@ 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,
@@ -300,7 +262,6 @@ 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()),
), ),
); );
@@ -310,7 +271,6 @@ 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();
@@ -347,11 +307,9 @@ 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',
], ],
@@ -371,12 +329,6 @@ 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,
@@ -384,7 +336,6 @@ 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()),
), ),
); );
@@ -395,7 +346,6 @@ class EmailRepositoryImpl implements EmailRepository {
htmlBody: htmlBody, htmlBody: htmlBody,
attachments: _parseAttachments(attachmentsJson), attachments: _parseAttachments(attachmentsJson),
headers: _parseHeaders(headersJson), headers: _parseHeaders(headersJson),
mimeTree: _parseMimeTree(mimeTreeJson),
); );
} }
@@ -578,7 +528,7 @@ class EmailRepositoryImpl implements EmailRepository {
imap.MessageSequence sequence, imap.MessageSequence sequence,
) async { ) async {
const fetchItems = const fetchItems =
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES LIST-UNSUBSCRIBE)])'; '(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
final fetch = sequence.isUidSequence final fetch = sequence.isUidSequence
? await client.uidFetchMessages(sequence, fetchItems) ? await client.uidFetchMessages(sequence, fetchItems)
: await client.fetchMessages(sequence, fetchItems); : await client.fetchMessages(sequence, fetchItems);
@@ -619,7 +569,6 @@ class EmailRepositoryImpl implements EmailRepository {
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();
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
final threadId = _computeThreadId( final threadId = _computeThreadId(
emailId: emailId, emailId: emailId,
messageId: msgId, messageId: msgId,
@@ -663,7 +612,6 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(inReplyTo), inReplyTo: Value(inReplyTo),
references: Value(refs), references: Value(refs),
snoozedUntil: Value(snoozedUntil), snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(listUnsubscribe),
), ),
); );
} }
@@ -1002,7 +950,6 @@ class EmailRepositoryImpl implements EmailRepository {
'htmlBody', 'htmlBody',
'bodyValues', 'bodyValues',
'attachments', 'attachments',
'header:List-Unsubscribe:asText',
]; ];
static const _emailGetBodyOptions = { static const _emailGetBodyOptions = {
@@ -1204,8 +1151,6 @@ class EmailRepositoryImpl implements EmailRepository {
final jmapReferences = _joinJmapStringList( final jmapReferences = _joinJmapStringList(
m['references'] as List<dynamic>?, m['references'] as List<dynamic>?,
); );
final jmapListUnsubscribe =
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
await _db.into(_db.emails).insertOnConflictUpdate( await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert( EmailsCompanion.insert(
@@ -1228,7 +1173,6 @@ class EmailRepositoryImpl implements EmailRepository {
inReplyTo: Value(jmapInReplyTo), inReplyTo: Value(jmapInReplyTo),
references: Value(jmapReferences), references: Value(jmapReferences),
snoozedUntil: Value(snoozedUntil), snoozedUntil: Value(snoozedUntil),
listUnsubscribeHeader: Value(jmapListUnsubscribe),
), ),
); );
@@ -1494,8 +1438,7 @@ 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)))
.getSingleOrNull(); .getSingle();
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) {
@@ -1567,70 +1510,12 @@ class EmailRepositoryImpl implements EmailRepository {
); );
} }
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
final account = (await _accounts.getAccount(accountId))!;
final unread = await (_db.select(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath) &
t.isSeen.equals(false),
))
.get();
if (unread.isEmpty) return;
await _db.transaction(() async {
for (final row in unread) {
if (account.type == account_model.AccountType.jmap) {
await _enqueueChange(
accountId,
row.id,
'flag_seen',
jsonEncode({'seen': true}),
);
} else {
await _enqueueChange(
accountId,
row.id,
'flag_seen',
jsonEncode({
'uid': row.uid,
'mailboxPath': row.mailboxPath,
'seen': true,
}),
);
}
}
// Bulk mark all unread emails in this mailbox as seen.
await (_db.update(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath) &
t.isSeen.equals(false),
))
.write(const EmailsCompanion(isSeen: Value(true)));
// Update all threads in this mailbox to reflect no unread.
await (_db.update(_db.threads)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath),
))
.write(const ThreadsCompanion(hasUnread: Value(false)));
});
}
@override @override
Future<void> moveEmail(String emailId, String destMailboxPath) async { Future<void> moveEmail(String emailId, String destMailboxPath) async {
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)))
.getSingleOrNull(); .getSingle();
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) {
@@ -1698,8 +1583,7 @@ 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)))
.getSingleOrNull(); .getSingle();
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.
@@ -1893,22 +1777,6 @@ 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) {
@@ -1940,221 +1808,6 @@ 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
@@ -2270,18 +1923,7 @@ class EmailRepositoryImpl implements EmailRepository {
.go(); .go();
applied++; applied++;
} catch (e) { } catch (e) {
if (_isImapNotFoundError(e)) { await _recordChangeError(row, 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 {
@@ -2290,19 +1932,13 @@ 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;
// snooze/unsnooze payloads use 'src' for the source folder; all others use 'mailboxPath'. final mailboxPath = payload['mailboxPath'] as String;
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);
@@ -2441,29 +2077,8 @@ 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';
var destMailboxId = payload['dest'] as String; final 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',
@@ -2827,17 +2442,11 @@ class EmailRepositoryImpl implements EmailRepository {
); );
try { try {
await client.selectMailboxByPath(emailRow.mailboxPath); await client.selectMailboxByPath(emailRow.mailboxPath);
// Fetch the full message so enough_mail has MIME headers (including final fetch = await client.uidFetchMessage(
// Content-Transfer-Encoding) and getPart() can decode the part correctly. emailRow.uid,
// A partial BODY.PEEK[n] fetch omits those headers, causing 'BODY.PEEK[${attachment.fetchPartId}]',
// decodeContentBinary() to return raw base64 instead of decoded bytes. );
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 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) {
@@ -2850,106 +2459,33 @@ 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,
String query, String query,
) async { ) async {
final ftsQuery = _toFtsQuery(query);
if (ftsQuery.isEmpty) return [];
final sql = accountId != null
? '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'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
final queryRows = await _db
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)),
);
return emailRows.map(_toModel).toList();
}
/// Converts a user query string into an FTS5 match expression.
/// Each whitespace-separated word becomes a prefix term (word*) so that
/// partial words still match. Special FTS5 characters are stripped.
static String _toFtsQuery(String query) {
final words = query final words = query
.trim() .toLowerCase()
.split(RegExp(r'\s+')) .split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty) .where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.where((w) => w.isNotEmpty)
.toList(); .toList();
if (words.isEmpty) return ''; final rows = await (_db.select(_db.emails)
return words.map((w) => '$w*').join(' '); ..where((t) {
Expression<bool> condition = const Constant(true);
if (accountId != null) {
condition = t.accountId.equals(accountId);
}
for (final word in words) {
final pattern = '%$word%';
condition = condition &
(t.subject.like(pattern) | t.preview.like(pattern));
}
return condition;
})
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(50))
.get();
return rows.map(_toModel).toList();
} }
@override @override
@@ -2975,77 +2511,6 @@ class EmailRepositoryImpl implements EmailRepository {
return rows.map(_toModel).toList(); return rows.map(_toModel).toList();
} }
@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 @override
Future<List<model.Email>> searchEmails( Future<List<model.Email>> searchEmails(
String accountId, String accountId,
@@ -3198,7 +2663,6 @@ class EmailRepositoryImpl implements EmailRepository {
references: row.references, references: row.references,
snoozedUntil: row.snoozedUntil, snoozedUntil: row.snoozedUntil,
snoozedFromMailboxPath: row.snoozedFromMailboxPath, snoozedFromMailboxPath: row.snoozedFromMailboxPath,
listUnsubscribeHeader: row.listUnsubscribeHeader,
); );
} }
@@ -3208,27 +2672,6 @@ 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) {
@@ -3305,17 +2748,14 @@ class EmailRepositoryImpl implements EmailRepository {
await _db.customStatement('PRAGMA foreign_keys = OFF'); await _db.customStatement('PRAGMA foreign_keys = OFF');
try { try {
await _db.transaction(() async { await _db.transaction(() async {
await (_db.delete( await (_db.delete(_db.emails)
_db.emails, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete( await (_db.delete(_db.pendingChanges)
_db.pendingChanges, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
await (_db.delete( await (_db.delete(_db.syncStates)
_db.syncStates, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
}); });
} finally { } finally {
@@ -3323,36 +2763,3 @@ 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,15 +79,6 @@ 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';
@@ -105,12 +96,6 @@ 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,
@@ -119,7 +104,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(role), role: Value(_imapRole(mb)),
), ),
); );
} }
@@ -321,112 +306,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async { Future<void> clearForResync(String accountId) async {
await (_db.delete( await (_db.delete(_db.mailboxes)
_db.mailboxes, ..where((t) => t.accountId.equals(accountId)))
)..where((t) => t.accountId.equals(accountId)))
.go(); .go();
} }
@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);
}
}
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, '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);
}
} }
@@ -1,59 +0,0 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
SearchHistoryRepositoryImpl(this._db);
final AppDatabase _db;
static const _maxEntries = 10;
@override
Future<List<String>> getRecentSearches() async {
final rows = await (_db.select(_db.searchHistoryEntries)
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
..limit(_maxEntries))
.get();
return rows.map((r) => r.query).toList();
}
@override
Future<void> saveSearch(String query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return;
await _db.transaction(() async {
// Remove existing entry for same query (deduplication).
await (_db.delete(
_db.searchHistoryEntries,
)..where((t) => t.query.equals(trimmed)))
.go();
await _db.into(_db.searchHistoryEntries).insert(
SearchHistoryEntriesCompanion.insert(
query: trimmed,
searchedAt: DateTime.now(),
),
);
// Prune to the most recent _maxEntries.
final keepIds = await (_db.select(_db.searchHistoryEntries)
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
..limit(_maxEntries))
.map((r) => r.id)
.get();
if (keepIds.isNotEmpty) {
await (_db.delete(
_db.searchHistoryEntries,
)..where((t) => t.id.isNotIn(keepIds)))
.go();
}
});
}
@override
Future<void> clearHistory() async {
await _db.delete(_db.searchHistoryEntries).go();
}
}
@@ -1,67 +0,0 @@
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,8 +13,6 @@ 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,
@@ -32,8 +30,6 @@ 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),
@@ -53,7 +49,6 @@ 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),
), ),
); );
} }
@@ -79,8 +74,6 @@ 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,
@@ -97,9 +90,6 @@ 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(),
@@ -1,117 +0,0 @@
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,
);
}
}
+8 -114
View File
@@ -3,40 +3,28 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/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/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';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/services/undo_service.dart'; 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' 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/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.
@@ -68,10 +56,6 @@ 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),
@@ -81,11 +65,7 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
}); });
final draftRepositoryProvider = Provider<DraftRepository>((ref) { final draftRepositoryProvider = Provider<DraftRepository>((ref) {
return DraftRepositoryImpl( return DraftRepositoryImpl(ref.watch(dbProvider));
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
}); });
final emailRepositoryProvider = Provider<EmailRepository>((ref) { final emailRepositoryProvider = Provider<EmailRepository>((ref) {
@@ -101,13 +81,7 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>(( final syncLogRepositoryProvider = Provider((ref) {
ref,
) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider)); return SyncLogRepositoryImpl(ref.watch(dbProvider));
}); });
@@ -136,13 +110,6 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
ref,
accountId,
) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) { final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager( final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider), ref.watch(accountRepositoryProvider),
@@ -150,8 +117,6 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
ref.watch(emailRepositoryProvider), ref.watch(emailRepositoryProvider),
syncLog: ref.watch(syncLogRepositoryProvider), syncLog: ref.watch(syncLogRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider), imapConnect: ref.watch(imapConnectProvider),
drafts: ref.watch(draftRepositoryProvider),
onNewMail: showNewMailNotification,
); );
ref.onDispose(manager.dispose); ref.onDispose(manager.dispose);
return manager; return manager;
@@ -170,10 +135,6 @@ 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),
@@ -188,59 +149,11 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
}); });
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>( final undoServiceProvider =
UndoService.new, StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
); final service = UndoService(ref);
unawaited(service.init());
/// Loads email header + body and marks the email as seen. return service;
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
final emailDetailProvider = AsyncNotifierProvider.autoDispose
.family<EmailDetailNotifier, (Email?, EmailBody), String>(
EmailDetailNotifier.new,
);
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
EmailDetailNotifier(this._emailId);
final String _emailId;
@override
Future<(Email?, EmailBody)> build() async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(_emailId),
repo.getEmailBody(_emailId),
]);
unawaited(repo.setFlag(_emailId, seen: true));
final header = results[0] as Email?;
if (header != null) {
unawaited(_prefetchNextEmailBody(repo, header));
}
return (results[0] as Email?, results[1] as EmailBody);
}
Future<void> _prefetchNextEmailBody(
EmailRepository repo,
Email header,
) async {
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return;
final threads =
await repo.observeThreads(header.accountId, header.mailboxPath).first;
final currentIndex = threads.indexWhere(
(t) => t.emailIds.contains(_emailId),
);
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
final nextId = threads[currentIndex + 1].latestEmailId;
await repo.getEmailBody(nextId);
}
}
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
return ref.watch(accountRepositoryProvider).observeAccounts();
}); });
final accountByIdProvider = final accountByIdProvider =
@@ -263,22 +176,3 @@ 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();
});
+6 -41
View File
@@ -1,34 +1,19 @@
import 'dart:async'; import 'dart:async';
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/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 []}) { void main({List<Override> overrides = const []}) async {
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,
@@ -47,38 +32,18 @@ void main({List<Override> overrides = const []}) {
}; };
await initDatabasePath(); await initDatabasePath();
if (Platform.isAndroid) {
await initNotifications();
await registerBackgroundSync();
await _registerPrefetchTaskFromStoredPrefs();
}
runApp( runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()), ProviderScope(overrides: overrides, child: const SharedInboxApp()),
); );
}, },
// This handler runs in the parent zone — runApp cannot be called here. (error, stack) {
// Framework errors are already handled by FlutterError.onError above. // Catch unhandled async errors.
(error, stack) => FlutterError.reportError( runApp(CrashScreen(exception: error, stackTrace: stack));
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});
@@ -98,7 +63,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp.router( return MaterialApp.router(
title: 'sharedinbox.de', title: 'SharedInbox',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true, useMaterial3: true,
+1 -48
View File
@@ -2,15 +2,10 @@ 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/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';
@@ -22,19 +17,14 @@ 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/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: '/inbox', initialLocation: '/accounts',
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(),
@@ -43,14 +33,6 @@ 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(),
@@ -59,14 +41,6 @@ final router = GoRouter(
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( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
@@ -91,21 +65,6 @@ 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) =>
@@ -170,12 +129,6 @@ final router = GoRouter(
); );
}, },
), ),
GoRoute(
path: '/bug-report',
builder: (ctx, state) => BugReportScreen(
emailId: state.uri.queryParameters['emailId'],
),
),
], ],
), ),
], ],
-234
View File
@@ -1,234 +0,0 @@
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'),
),
),
],
),
),
],
),
);
},
);
}
}
+93 -337
View File
@@ -1,13 +1,11 @@
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});
@@ -16,7 +14,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.de'), title: const Text('SharedInbox'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
@@ -31,18 +29,10 @@ class AccountListScreen extends ConsumerWidget {
const DrawerHeader( const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey), decoration: BoxDecoration(color: Colors.blueGrey),
child: Text( child: Text(
'sharedinbox.de', 'SharedInbox',
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'),
@@ -59,47 +49,37 @@ 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: Column( body: StreamBuilder(
children: [ stream: ref.watch(accountRepositoryProvider).observeAccounts(),
const _UpdateBanner(), builder: (ctx, snap) {
Expanded( if (!snap.hasData) {
child: StreamBuilder( return const Center(child: CircularProgressIndicator());
stream: ref.watch(accountRepositoryProvider).observeAccounts(), }
builder: (ctx, snap) { final accounts = snap.data!;
if (!snap.hasData) { if (accounts.isEmpty) {
return const Center(child: CircularProgressIndicator()); return Center(
} child: Column(
final accounts = snap.data!; mainAxisSize: MainAxisSize.min,
if (accounts.isEmpty) { children: [
return const _OnboardingView(); const Text('No accounts yet.'),
} const SizedBox(height: 12),
return ListView.builder( FilledButton.icon(
itemCount: accounts.length, onPressed: () => context.push('/accounts/add'),
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]), icon: const Icon(Icons.add),
); 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'),
@@ -120,80 +100,20 @@ 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 Column( return ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
ListTile( subtitle: Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Text('${account.email}\n$typeLabel'), Text('${account.email}\n$typeLabel'),
isThreeLine: true, const SizedBox(height: 4),
trailing: Row( health.when(
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(
@@ -202,13 +122,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)), Text(' ($date)', style: const TextStyle(fontSize: 10)),
], ],
); );
@@ -216,8 +130,54 @@ 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'),
); );
} }
@@ -234,53 +194,16 @@ class _AccountTile extends ConsumerWidget {
); );
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(content: Text('Starting sync verification...')),
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.emailFiltersRemote: case _AccountAction.emailFilters:
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,
@@ -310,146 +233,7 @@ class _AccountTile extends ConsumerWidget {
} }
} }
String _formatDiscrepancies(String? summary) { enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
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].
/// ///
@@ -461,31 +245,3 @@ 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(),
);
}
}
-453
View File
@@ -1,453 +0,0 @@
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]),
),
],
);
}
}
-383
View File
@@ -1,383 +0,0 @@
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: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';
/// Sending side of the secure account-sharing flow.
///
/// Step 1 scans (or pastes) the receiver's public-key QR code.
///
/// Step 2 if more than one account exists, the user selects which accounts
/// to transfer (auto-selected when only one account is present).
///
/// Step 3 shows the encrypted-accounts QR code for the receiver to scan.
class AccountSendScreen extends ConsumerStatefulWidget {
const AccountSendScreen({super.key});
@override
ConsumerState<AccountSendScreen> createState() => _AccountSendScreenState();
}
enum _Step { scanning, selectAccounts, showEncrypted, error }
class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
_Step _step = _Step.scanning;
// Set after scanning the pubkey QR.
Uint8List? _recipientKeyId;
Uint8List? _recipientPublicKey;
// All available accounts + the selection (for step 2).
List<Account> _accounts = [];
final Set<String> _selectedIds = {};
// Set after encryption (step 3).
String? _encryptedQr;
String? _errorMessage;
bool _scannerActive = true;
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();
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);
}
}
@override
void dispose() {
final ctrl = _scannerController;
if (ctrl != null) unawaited(ctrl.dispose());
super.dispose();
}
// ── Step 1: scan pubkey QR ──────────────────────────────────────────────────
Future<void> _onPubKeyScanned(String rawValue) async {
if (!_scannerActive) return;
_scannerActive = false;
await _scannerController?.stop();
final parsed = ShareEncryptionService.parsePublicKeyQr(rawValue);
if (parsed == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Not a valid sharedinbox.de public-key QR code. '
'Ask the receiver to show step 1 of "Receive accounts".',
),
),
);
// Allow retry.
setState(() => _scannerActive = true);
await _scannerController?.start();
}
return;
}
// Load all available accounts.
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (!mounted) return;
if (accounts.isEmpty) {
setState(() {
_errorMessage = 'No accounts to send.';
_step = _Step.error;
});
return;
}
setState(() {
_recipientKeyId = parsed.keyId;
_recipientPublicKey = parsed.publicKeyBytes;
_accounts = accounts;
});
if (accounts.length == 1) {
// Auto-select the only account; skip the selection step.
_selectedIds.add(accounts.first.id);
await _encryptAndShow();
} else {
setState(() {
_selectedIds.addAll(accounts.map((a) => a.id));
_step = _Step.selectAccounts;
});
}
}
// ── Step 2: account selection ───────────────────────────────────────────────
Future<void> _encryptAndShow() async {
final repo = ref.read(accountRepositoryProvider);
final selected = _accounts.where((a) => _selectedIds.contains(a.id));
final payloads = <AccountPayload>[];
for (final account in selected) {
final password = await repo.getPassword(account.id);
payloads.add(
AccountPayload(accountJson: account.toJson(), password: password),
);
}
try {
final qr = await ShareEncryptionService.encryptAccounts(
recipientKeyId: _recipientKeyId!,
recipientPublicKeyBytes: _recipientPublicKey!,
accounts: payloads,
);
if (mounted) {
setState(() {
_encryptedQr = qr;
_step = _Step.showEncrypted;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_step = _Step.error;
});
}
}
}
// ── Build ───────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Send accounts')),
body: switch (_step) {
_Step.scanning => _buildScanStep(context),
_Step.selectAccounts => _buildSelectStep(context),
_Step.showEncrypted => _buildEncryptedQrStep(context),
_Step.error => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Error: $_errorMessage'),
),
),
},
);
}
Widget _buildScanStep(BuildContext context) {
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(_onPubKeyScanned(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 public-key QR code shown by the receiver',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
],
);
}
Widget _buildTextFallbackView(BuildContext context) {
final ctrl = TextEditingController();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Paste the public key shown by the receiver\'s "Receive accounts" screen.',
),
const SizedBox(height: 16),
TextField(
key: const Key('pubKeyInputField'),
controller: ctrl,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Public key',
border: OutlineInputBorder(),
hintText: 'sharedinbox.de:pubkey:v1:…',
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final text = ctrl.text.trim();
if (text.isNotEmpty) unawaited(_onPubKeyScanned(text));
},
child: const Text('Continue'),
),
],
),
);
}
Widget _buildSelectStep(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Select accounts to send',
style: theme.textTheme.titleMedium,
),
),
Expanded(
child: ListView(
children: _accounts.map((account) {
final selected = _selectedIds.contains(account.id);
return CheckboxListTile(
value: selected,
title: Text(account.displayName),
subtitle: Text(account.email),
onChanged: (v) {
setState(() {
if (v == true) {
_selectedIds.add(account.id);
} else {
_selectedIds.remove(account.id);
}
});
},
);
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
key: const Key('sendSelectedButton'),
onPressed: _selectedIds.isEmpty
? null
: () => unawaited(_encryptAndShow()),
child: const Text('Encrypt & show QR'),
),
),
],
);
}
Widget _buildEncryptedQrStep(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Step 3 — Show this QR code to the receiver',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The receiver taps "Step 2 — Scan encrypted QR code" and scans this.',
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('encryptedAccountsQrCode'),
data: _encryptedQr!,
size: 280,
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
key: const Key('copyEncryptedButton'),
icon: const Icon(Icons.copy),
label: const Text('Copy encrypted code'),
onPressed: () {
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Encrypted code copied to clipboard'),
),
);
},
),
const SizedBox(height: 8),
Text(
'This code contains encrypted account data. It is safe to display '
'briefly — only the receiver\'s device can decrypt it.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
);
}
}
bool _cameraScanSupported() =>
Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows;
-7
View File
@@ -295,13 +295,6 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
onPressed: _detectAccount, onPressed: _detectAccount,
child: const Text('Continue'), child: const Text('Continue'),
), ),
const SizedBox(height: 8),
OutlinedButton.icon(
key: const Key('importAccountButton'),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Receive account'),
onPressed: () => context.push('/accounts/receive'),
),
], ],
), ),
), ),
-635
View File
@@ -1,635 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.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:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
const _bugReportApiUrl = String.fromEnvironment(
'BUG_REPORT_API_URL',
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
);
class BugReportScreen extends ConsumerStatefulWidget {
const BugReportScreen({super.key, this.emailId});
final String? emailId;
@override
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
}
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _emailController = TextEditingController();
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture = getDeviceModel();
final List<PlatformFile> _attachments = [];
bool _includeEmail = false;
bool _includeSyncLog = false;
bool _submitting = false;
Email? _attachedEmail;
List<Account> _accounts = [];
String? _selectedAccountId;
String? _deviceModel;
bool _loadingEmail = false;
@override
void initState() {
super.initState();
unawaited(_loadInitialData());
}
@override
void dispose() {
_descriptionController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _loadInitialData() async {
setState(() => _loadingEmail = true);
try {
_deviceModel = await _deviceModelFuture;
_accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (widget.emailId != null) {
final email =
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
if (mounted && email != null) {
_attachedEmail = email;
_selectedAccountId = email.accountId;
final fromStr =
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
final subjectStr = email.subject ?? '(no subject)';
_descriptionController.text =
'Problem with email from $fromStr: "$subjectStr"\n\n';
}
}
if (_selectedAccountId == null && _accounts.isNotEmpty) {
_selectedAccountId = _accounts.first.id;
}
if (_selectedAccountId != null) {
final matching =
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
if (matching != null) {
_emailController.text = matching.email;
}
}
} catch (_) {}
if (mounted) {
setState(() => _loadingEmail = false);
}
}
int get _totalAttachmentSize {
return _attachments.fold(0, (sum, f) => sum + f.size);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
Future<void> _pickAttachments() async {
try {
final result = await FilePicker.pickFiles();
if (result == null) return;
final newFiles =
result.files.where((PlatformFile f) => f.path != null).toList();
if (!mounted) return;
setState(() {
_attachments.addAll(newFiles);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick files: $e')),
);
}
}
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
}
String _serializeSyncLogs(List<SyncLogEntry> entries) {
final sb = StringBuffer();
for (final entry in entries.take(50)) {
sb.writeln('ID: ${entry.id}');
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
sb.writeln('Result: ${entry.result}');
if (entry.errorMessage != null) {
sb.writeln('Error: ${entry.errorMessage}');
}
if (entry.stackTrace != null) {
sb.writeln('StackTrace:\n${entry.stackTrace}');
}
sb.writeln('Protocol: ${entry.protocol}');
sb.writeln(
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
);
if (entry.protocolLog != null) {
sb.writeln('Protocol Log:\n${entry.protocolLog}');
}
sb.writeln('---');
}
return sb.toString();
}
Future<void> _submitReport() async {
if (!_formKey.currentState!.validate()) return;
final totalSize = _totalAttachmentSize;
if (totalSize > 20 * 1024 * 1024) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
),
backgroundColor: Colors.red,
),
);
return;
}
setState(() => _submitting = true);
try {
final client = ref.read(httpClientProvider);
final uri = Uri.parse(_bugReportApiUrl);
final request = http.MultipartRequest('POST', uri);
// Description
request.fields['description'] = _descriptionController.text;
// Email Data if from email view
if (_attachedEmail != null) {
final emailMap = {
'id': _attachedEmail!.id,
'subject': _attachedEmail!.subject,
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
'date': _attachedEmail!.sentAt?.toIso8601String() ??
_attachedEmail!.receivedAt.toIso8601String(),
'preview': _attachedEmail!.preview,
};
request.fields['email_data'] = jsonEncode(emailMap);
}
// Contact Email
if (_includeEmail) {
request.fields['email'] = _emailController.text;
}
// About Info
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
final imapCount =
_accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount =
_accounts.where((a) => a.type == AccountType.jmap).length;
if (!mounted) return;
final aboutInfo = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
request.fields['about_info'] = aboutInfo;
// Sync Log
if (_includeSyncLog && _selectedAccountId != null) {
final syncLogs = await ref
.read(syncLogRepositoryProvider)
.observeSyncLogs(_selectedAccountId!)
.first;
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
}
// Attachments
for (final file in _attachments) {
final multipartFile = await http.MultipartFile.fromPath(
'attachments[]',
file.path!,
filename: file.name,
);
request.files.add(multipartFile);
}
final streamedResponse = await client.send(request);
final response = await http.Response.fromStream(streamedResponse);
if (!mounted) return;
if (response.statusCode == 201) {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
final reportId = resData['id'] as String;
_showSuccessDialog(reportId);
} else if (response.statusCode == 429) {
final retryAfter = response.headers['retry-after'] ?? '6';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
backgroundColor: Colors.orange,
),
);
} else {
String errorMsg =
'Failed to submit report. Server returned status: ${response.statusCode}';
try {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
if (resData['error'] != null) {
errorMsg = resData['error'] as String;
}
} catch (_) {}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An error occurred: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
void _showSuccessDialog(String reportId) {
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Bug Report Submitted'),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('Thank you for helping us improve SharedInbox!'),
const SizedBox(height: 12),
Text(
'Your Report ID is:\n$reportId',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your report is handled confidentially and has not been posted to the public issue tracker.',
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Dismiss dialog
context.pop(); // Go back to previous screen
},
child: const Text('Close'),
),
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalSize = _totalAttachmentSize;
const sizeLimit = 20 * 1024 * 1024;
final approachingLimit = totalSize > 15 * 1024 * 1024;
return Scaffold(
appBar: AppBar(
title: const Text('Report a Bug'),
),
body: _loadingEmail
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Confidentiality info card
Card(
elevation: 0,
color: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
side: BorderSide(
color:
theme.colorScheme.secondary.withValues(alpha: 0.4),
),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.lock_outline,
color: theme.colorScheme.secondary,
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Your report is handled confidentially and will not be posted to the public issue tracker.',
style: TextStyle(height: 1.3),
),
),
],
),
),
),
const SizedBox(height: 20),
// Description Text Field
TextFormField(
controller: _descriptionController,
autofocus: true,
maxLines: 8,
minLines: 4,
decoration: const InputDecoration(
labelText: 'What went wrong?',
alignLabelWithHint: true,
border: OutlineInputBorder(),
helperText:
'Please describe the problem and how to reproduce it.',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a description.';
}
return null;
},
),
const SizedBox(height: 20),
// Email info chip if email is attached
if (_attachedEmail != null) ...[
Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: Row(
children: [
Icon(
Icons.email_outlined,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'The current email metadata will be attached automatically.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Attachments Section
Text(
'Attachments',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickAttachments,
icon: const Icon(Icons.add_a_photo_outlined),
label: const Text('Add screenshots'),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Screenshots help us understand the problem faster.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
),
if (_attachments.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _attachments.length,
itemBuilder: (context, index) {
final file = _attachments[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: InputChip(
label: Text(
'${file.name} (${_formatSize(file.size)})',
),
onDeleted: _submitting
? null
: () => _removeAttachment(index),
),
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
style: TextStyle(
fontSize: 12,
color: totalSize > sizeLimit
? Colors.red
: approachingLimit
? Colors.orange
: Colors.grey,
fontWeight: approachingLimit
? FontWeight.bold
: FontWeight.normal,
),
),
if (totalSize > sizeLimit) ...[
const SizedBox(width: 8),
const Icon(
Icons.error_outline,
size: 16,
color: Colors.red,
),
],
],
),
],
const SizedBox(height: 24),
// Email opt-in
CheckboxListTile(
title: const Text('Include my email for follow-up'),
value: _includeEmail,
onChanged: _submitting
? null
: (val) {
setState(() => _includeEmail = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
if (_includeEmail) ...[
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Contact Email Address',
border: OutlineInputBorder(),
),
validator: (value) {
if (_includeEmail &&
(value == null || value.trim().isEmpty)) {
return 'Please enter an email address.';
}
return null;
},
),
),
],
// Sync log opt-in
if (_selectedAccountId != null) ...[
CheckboxListTile(
title: const Text('Include recent sync log'),
subtitle: const Text(
'Helps diagnose connection and protocol issues.',
),
value: _includeSyncLog,
onChanged: _submitting
? null
: (val) {
setState(() => _includeSyncLog = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 12),
],
// System info section
FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
final imapCount = _accounts
.where((a) => a.type == AccountType.imap)
.length;
final jmapCount = _accounts
.where((a) => a.type == AccountType.jmap)
.length;
final aboutMd = buildAboutMarkdown(
context: context,
pkg: snapshot.data,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.1),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpansionTile(
title: const Text(
'System Info (attached automatically)',
style: TextStyle(fontSize: 14),
),
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Align(
alignment: Alignment.topLeft,
child: MarkdownBody(data: aboutMd),
),
),
],
),
);
},
),
const SizedBox(height: 32),
// Submit Button
FilledButton(
onPressed: _submitting ? null : _submitReport,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Send Bug Report',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
}
+3 -4
View File
@@ -1,7 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class ChangeLogScreen extends StatelessWidget { class ChangeLogScreen extends StatelessWidget {
@@ -12,9 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')), appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>( body: FutureBuilder<String>(
future: DefaultAssetBundle.of( future: rootBundle.loadString('assets/changelog.txt'),
context,
).loadString('assets/changelog.txt'),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
-393
View File
@@ -1,393 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@override
ConsumerState<CombinedInboxScreen> createState() =>
_CombinedInboxScreenState();
}
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
return accountsAsync.when(
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
body: Center(child: Text('Error: $e')),
),
data: (accounts) {
if (accounts.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) context.go('/accounts');
});
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final accountNames = {
for (final a in accounts) a.id: a.displayName,
};
final showAccount = accounts.length > 1;
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _buildDrawer(context, accounts),
body: _buildBody(accountNames, showAccount),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
return AppBar(
title: const Text('Combined Inbox'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync all',
onPressed: () {
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
),
],
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text(
'sharedinbox.de',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
ListTile(
leading: const Icon(Icons.manage_accounts),
title: const Text('Accounts'),
onTap: () {
Navigator.pop(context);
context.go('/accounts');
},
),
ListTile(
leading: const Icon(Icons.person_add),
title: const Text('Add account'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/add'));
},
),
const Divider(),
for (final account in accounts)
ListTile(
leading: const Icon(Icons.inbox),
title: Text(account.displayName),
subtitle: Text(account.email),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/${account.id}/mailboxes'));
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/preferences'));
},
),
ListTile(
leading: const Icon(Icons.history),
title: const Text('Undo Log'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/undo-log'));
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
onTap: () {
Navigator.pop(context);
unawaited(context.push('/accounts/about'));
},
),
],
),
);
}
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
final emailRepo = ref.watch(emailRepositoryProvider);
return RefreshIndicator(
onRefresh: () async {
final accounts = ref.read(allAccountsProvider).value ?? [];
for (final a in accounts) {
ref.read(syncManagerProvider).syncNow(a.id);
}
},
child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeAllInboxThreads(limit: _limit),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
if (threads.isEmpty) {
return ListView(
children: const [
SizedBox(
height: 300,
child: Center(child: Text('No emails')),
),
],
);
}
return _buildThreadList(threads, accountNames, showAccount);
},
),
);
}
Widget _buildThreadList(
List<EmailThread> threads,
Map<String, String> accountNames,
bool showAccount,
) {
final hasMore = threads.length == _limit;
return ListView.builder(
itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
},
);
}
Widget _buildThreadTile(
BuildContext ctx,
EmailThread t,
Map<String, String> accountNames,
bool showAccount,
) {
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
if (showAccount)
Text(
accountNames[t.accountId] ?? t.accountId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
child: tile,
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(t.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+12 -115
View File
@@ -39,8 +39,6 @@ class ComposeScreen extends ConsumerStatefulWidget {
class _ComposeScreenState extends ConsumerState<ComposeScreen> { class _ComposeScreenState extends ConsumerState<ComposeScreen> {
final _to = TextEditingController(); final _to = TextEditingController();
final _cc = TextEditingController(); final _cc = TextEditingController();
final _toFocus = FocusNode();
final _ccFocus = FocusNode();
final _subject = TextEditingController(); final _subject = TextEditingController();
final _body = TextEditingController(); final _body = TextEditingController();
String? _accountId; String? _accountId;
@@ -141,8 +139,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
c.removeListener(_onTextChanged); c.removeListener(_onTextChanged);
c.dispose(); c.dispose();
} }
_toFocus.dispose();
_ccFocus.dispose();
// Flush any pending save synchronously — we can't await in dispose, but // Flush any pending save synchronously — we can't await in dispose, but
// scheduling a microtask still runs before the isolate exits. // scheduling a microtask still runs before the isolate exits.
if (_draftDirty) { if (_draftDirty) {
@@ -162,7 +158,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
} }
Future<void> _pickAttachments() async { Future<void> _pickAttachments() async {
final result = await FilePicker.pickFiles(); final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return; if (result == null) return;
final files = result.files.where((f) => f.path != null).toList(); final files = result.files.where((f) => f.path != null).toList();
if (!mounted) return; if (!mounted) return;
@@ -194,12 +190,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await OpenFilex.open(path); await OpenFilex.open(path);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar( context,
duration: const Duration(seconds: 5), ).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
content: Text('Failed to open file: $e'),
),
);
} finally { } finally {
if (mounted) setState(() => _opening = false); if (mounted) setState(() => _opening = false);
} }
@@ -211,12 +204,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async { Future<void> _send() async {
if (_accountId == null) { if (_accountId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar( context,
duration: Duration(seconds: 5), ).showSnackBar(const SnackBar(content: Text('Select an account first')));
content: Text('Select an account first'),
),
);
return; return;
} }
setState(() => _sending = true); setState(() => _sending = true);
@@ -251,12 +241,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (mounted) context.pop(); if (mounted) context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar( context,
duration: const Duration(seconds: 5), ).showSnackBar(SnackBar(content: Text('Send failed: $e')));
content: Text('Send failed: $e'),
),
);
} finally { } finally {
if (mounted) setState(() => _sending = false); if (mounted) setState(() => _sending = false);
} }
@@ -328,8 +315,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
), ),
), ),
), ),
_addressField(_to, _toFocus, 'To'), _field(_to, 'To', keyboardType: TextInputType.emailAddress),
_addressField(_cc, _ccFocus, 'Cc'), _field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
_field(_subject, 'Subject'), _field(_subject, 'Subject'),
const SizedBox(height: 8), const SizedBox(height: 8),
TextFormField( TextFormField(
@@ -382,96 +369,6 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
); );
} }
Widget _addressField(
TextEditingController ctrl,
FocusNode focusNode,
String label,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: RawAutocomplete<EmailAddress>(
textEditingController: ctrl,
focusNode: focusNode,
displayStringForOption: (option) {
final text = ctrl.text;
final lastComma = text.lastIndexOf(',');
final prefix =
lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : '';
return '$prefix${option.email}, ';
},
optionsBuilder: (value) async {
final text = value.text;
final lastComma = text.lastIndexOf(',');
final token = lastComma >= 0
? text.substring(lastComma + 1).trim()
: text.trim();
if (token.length < 2) return const [];
final results = await ref
.read(emailRepositoryProvider)
.searchAddresses(null, token);
// Guard: if focus left the field while the query was running,
// return empty so RawAutocomplete doesn't call show() after hide()
// has already been called — that races into an assertion in overlay.dart.
if (!focusNode.hasFocus) return const [];
return results;
},
fieldViewBuilder: (ctx, fieldCtrl, fieldFocusNode, onFieldSubmitted) {
return TextFormField(
controller: fieldCtrl,
focusNode: fieldFocusNode,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
onFieldSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (ctx, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (ctx, i) {
final option = options.elementAt(i);
return InkWell(
onTap: () => onSelected(option),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: option.name != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(option.name!),
Text(
option.email,
style: const TextStyle(fontSize: 12),
),
],
)
: Text(option.email),
),
);
},
),
),
),
);
},
),
);
}
Widget _field( Widget _field(
TextEditingController ctrl, TextEditingController ctrl,
String label, { String label, {
+79 -198
View File
@@ -1,9 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class CrashScreen extends StatelessWidget { class CrashScreen extends StatelessWidget {
@@ -11,48 +7,10 @@ class CrashScreen extends StatelessWidget {
super.key, super.key,
required this.exception, required this.exception,
required this.stackTrace, required this.stackTrace,
this.gitHash = const String.fromEnvironment('GIT_HASH'),
}); });
final Object exception; final Object exception;
final StackTrace? stackTrace; final StackTrace? stackTrace;
final String gitHash;
String get _buildMode {
if (kDebugMode) return 'debug';
if (kProfileMode) return 'profile';
return 'release';
}
Future<String> _fetchVersion() async {
try {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
} catch (_) {
return 'unknown';
}
}
Future<String> _buildReport() async {
final version = await _fetchVersion();
final platform =
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
final versionDisplay = gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
: version;
final gitLine = gitHash.isNotEmpty
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
: '';
final timestamp = DateTime.now().toUtc().toIso8601String();
return 'App Version: $versionDisplay\n'
'Build Mode: $_buildMode\n'
'$gitLine'
'Platform: $platform\n'
'Dart: ${Platform.version}\n'
'Timestamp: $timestamp\n\n'
'Error:\n```\n$exception\n```\n\n'
'Stack Trace:\n```\n$stackTrace\n```';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -62,86 +20,39 @@ class CrashScreen extends StatelessWidget {
title: const Text('Something went wrong'), title: const Text('Something went wrong'),
backgroundColor: Theme.of(context).colorScheme.errorContainer, backgroundColor: Theme.of(context).colorScheme.errorContainer,
), ),
body: Builder( body: SingleChildScrollView(
builder: (ctx) => SingleChildScrollView( padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ const Icon(Icons.error_outline, color: Colors.red, size: 64),
const Icon(Icons.error_outline, color: Colors.red, size: 64), const SizedBox(height: 16),
Text(
'SharedInbox encountered an unexpected error and needs to be restarted.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
const Text(
'Error Details:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
exception.toString(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
if (stackTrace != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text(
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
FutureBuilder<String>(
future: _fetchVersion(),
builder: (context, snapshot) => Text(
'v${snapshot.data ?? ''}$_buildMode'
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
),
if (gitHash.isNotEmpty) ...[
const SizedBox(height: 8),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final version =
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
return GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'App Version: $version',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
},
),
const SizedBox(height: 4),
GestureDetector(
onTap: () async {
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
);
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
},
child: Text(
'Git Commit: $gitHash',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
),
],
const SizedBox(height: 24),
const Text( const Text(
'Error Details:', 'Stack Trace:',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -152,94 +63,64 @@ class CrashScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
exception.toString(), stackTrace.toString(),
style: const TextStyle( style: const TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 10,
), ),
), ),
), ),
if (stackTrace != null) ...[ ],
const SizedBox(height: 16), const SizedBox(height: 24),
const Text( FilledButton.icon(
'Stack Trace:', onPressed: () async {
style: TextStyle(fontWeight: FontWeight.bold), final data = 'Error: $exception\n\nStack Trace:\n$stackTrace';
), await Clipboard.setData(ClipboardData(text: data));
const SizedBox(height: 8), if (context.mounted) {
Container( ScaffoldMessenger.of(context).showSnackBar(
padding: const EdgeInsets.all(12), const SnackBar(content: Text('Copied to clipboard')),
decoration: BoxDecoration( );
color: Colors.grey[200], }
borderRadius: BorderRadius.circular(8), },
), icon: const Icon(Icons.copy),
child: Text( label: const Text('Copy to Clipboard'),
stackTrace.toString(), ),
style: const TextStyle( const SizedBox(height: 16),
fontFamily: 'monospace', OutlinedButton.icon(
fontSize: 10, onPressed: () async {
), final title = Uri.encodeComponent(
), 'Crash: ${exception.toString().split('\n').first}',
), );
], final body = Uri.encodeComponent(
const SizedBox(height: 24), 'Error: $exception\n\nStack Trace:\n$stackTrace',
FilledButton.icon( );
onPressed: () async { final url = Uri.parse(
final data = await _buildReport(); 'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
await Clipboard.setData(ClipboardData(text: data)); );
if (ctx.mounted) { try {
ScaffoldMessenger.of(ctx).showSnackBar( final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
duration: Duration(seconds: 5), content: Text('Could not open browser.'),
content: Text('Copied to clipboard'),
), ),
); );
} }
}, } catch (e) {
icon: const Icon(Icons.copy), if (context.mounted) {
label: const Text('Copy to Clipboard'), ScaffoldMessenger.of(
), context,
const SizedBox(height: 16), ).showSnackBar(SnackBar(content: Text('Error: $e')));
OutlinedButton.icon(
onPressed: () async {
// URL carries only the title to avoid exceeding browser
// URL-length limits — long stack traces caused "create
// issue failed" (#146). Use "Copy to Clipboard" first to
// get the full report, then paste it in the issue body.
final title = Uri.encodeComponent(
'Crash: ${exception.toString().split('\n').first}',
);
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title',
);
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
} }
}, }
icon: const Icon(Icons.bug_report), },
label: const Text('Report Issue on Codeberg'), icon: const Icon(Icons.bug_report),
), label: const Text('Report Issue on Codeberg'),
], ),
), ],
), ),
), ),
), ),
+51 -21
View File
@@ -38,12 +38,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
var _sieveSsl = true; var _sieveSsl = true;
var _verbose = false; var _verbose = false;
final _jmapUrlCtrl = TextEditingController(); final _jmapUrlCtrl = TextEditingController();
bool _hasStoredPassword = false;
// -- "Try connection" state ------------------------------------------------ // -- "Try connection" state ------------------------------------------------
bool _tryTesting = false; bool _tryTesting = false;
String? _tryOk; String? _tryOk;
String? _tryErr; String? _tryErr;
bool _resyncing = false;
@override @override
void initState() { void initState() {
@@ -51,7 +51,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild); _smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild); _sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild); _imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load()); unawaited(_load());
} }
@@ -65,11 +64,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
context.pop(); context.pop();
return; return;
} }
try {
await repo.getPassword(account.id);
_hasStoredPassword = true;
} catch (_) {}
if (!mounted) return;
_account = account; _account = account;
_displayNameCtrl.text = account.displayName; _displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username; _usernameCtrl.text = account.username;
@@ -91,7 +85,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild); _smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild); _sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild); _imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [ for (final c in [
_displayNameCtrl, _displayNameCtrl,
_usernameCtrl, _usernameCtrl,
@@ -178,6 +171,43 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
} }
} }
Future<void> _forceResync() async {
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 || !mounted) return;
setState(() => _resyncing = true);
try {
await ref.read(syncManagerProvider).forceResync(widget.accountId);
if (mounted) context.pop();
} catch (e) {
if (mounted) {
setState(() {
_resyncing = false;
_errorMessage = 'Force sync failed: $e';
});
}
}
}
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null; final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
@@ -238,7 +268,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Edit account')), appBar: AppBar(title: const Text('Edit account')),
body: _loading || _saving body: _loading || _saving || _resyncing
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _buildForm(), : _buildForm(),
); );
@@ -275,12 +305,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
), ),
_field( _field(
_passwordCtrl, _passwordCtrl,
_hasStoredPassword 'New password (leave blank to keep)',
? 'New password (leave blank to keep)'
: 'Password',
key: const Key('editPasswordField'), key: const Key('editPasswordField'),
obscure: true, obscure: true,
required: !_hasStoredPassword, required: false,
), ),
if (account.type == AccountType.jmap) ...[ if (account.type == AccountType.jmap) ...[
const Divider(height: 32), const Divider(height: 32),
@@ -355,16 +383,18 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting, testing: _tryTesting,
okMessage: _tryOk, okMessage: _tryOk,
errorMessage: _tryErr, errorMessage: _tryErr,
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty onPressed: _tryConnection,
? _tryConnection
: null,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton( FilledButton(onPressed: _save, child: const Text('Save')),
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty const SizedBox(height: 8),
? _save OutlinedButton.icon(
: null, icon: const Icon(Icons.sync_problem),
child: const Text('Save'), label: const Text('Force full sync'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: _forceResync,
), ),
], ],
), ),
-80
View File
@@ -1,80 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m in mailboxes.where(
(m) => m.path != currentMailboxPath,
))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
File diff suppressed because it is too large Load Diff
+71 -219
View File
@@ -8,23 +8,12 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
final _dateFmt = DateFormat('MMM d'); final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day so DateFormat.format is called
// at most once per unique date rather than once per list item per rebuild.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class EmailListScreen extends ConsumerStatefulWidget { class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({ const EmailListScreen({
@@ -55,10 +44,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
List<EmailThread> _currentThreads = []; List<EmailThread> _currentThreads = [];
// Individual email selection used in search results. // Individual email selection used in search results.
final Set<String> _selectedSearchIds = {}; final Set<String> _selectedSearchIds = {};
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
bool get _selecting => bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty; _selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -96,16 +81,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
_selectedSearchIds.clear(); _selectedSearchIds.clear();
}); });
void _selectAll() {
setState(() {
if (_searching) {
_selectedSearchIds.addAll(_searchResults?.map((e) => e.id) ?? []);
} else {
_selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId));
}
});
}
void _toggleSearchSelection(String emailId) { void _toggleSearchSelection(String emailId) {
setState(() { setState(() {
if (_selectedSearchIds.contains(emailId)) { if (_selectedSearchIds.contains(emailId)) {
@@ -149,21 +124,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom), appBar: _buildAppBar(repo, accountAsync),
drawer: _selecting drawer: _selecting
? null ? null
: FolderDrawer( : FolderDrawer(
accountId: widget.accountId, accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath, currentMailboxPath: widget.mailboxPath,
), ),
bottomNavigationBar: _selecting bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
body: Column( body: Column(
children: [ children: [
_buildSyncErrorBanner(), _buildSyncErrorBanner(),
@@ -179,14 +149,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar( PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo, EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, { AsyncValue<Account?> accountAsync,
required bool menuAtBottom, ) {
}) {
final selectionCount = final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length; _searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar( return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting leading: _selecting
? IconButton( ? IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -197,13 +165,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
? Text('$selectionCount selected') ? Text('$selectionCount selected')
: Text(widget.mailboxPath), : Text(widget.mailboxPath),
actions: _selecting actions: _selecting
? [ ? []
IconButton(
icon: const Icon(Icons.select_all),
tooltip: 'Select all',
onPressed: _selectAll,
),
]
: [ : [
accountAsync.when( accountAsync.when(
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
@@ -218,7 +180,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
), ),
), ),
), ),
_buildSyncButton(emailRepo), IconButton(
icon: const Icon(Icons.sync),
onPressed: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
}
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => context.push( onPressed: () => context.push(
@@ -226,22 +203,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
extra: {'accountId': widget.accountId}, extra: {'accountId': widget.accountId},
), ),
), ),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'mark_all_read') {
await emailRepo.markAllAsRead(
widget.accountId,
widget.mailboxPath,
);
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'mark_all_read',
child: Text('Mark all as read'),
),
],
),
], ],
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size.fromHeight(60), preferredSize: const Size.fromHeight(60),
@@ -268,63 +229,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
: hasError
? 'Sync error'
: 'Sync',
icon: isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: hasError
? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Sync failed: $e'),
),
);
}
},
);
}
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
@@ -375,13 +279,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget _buildSyncErrorBanner() { Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId)); final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.value; final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) { if (error == null || error == _dismissedError) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return MaterialBanner( return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis), content: Text(
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon( leading: Icon(
Icons.sync_problem, Icons.sync_problem,
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
@@ -394,11 +302,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}, },
child: const Text('Retry'), child: const Text('Retry'),
), ),
TextButton(
onPressed: () =>
context.push('/accounts/${widget.accountId}/sync-log'),
child: const Text('View log'),
),
TextButton( TextButton(
onPressed: () => setState(() => _dismissedError = error), onPressed: () => setState(() => _dismissedError = error),
child: const Text('Dismiss'), child: const Text('Dismiss'),
@@ -416,11 +319,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath); await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
}, },
child: StreamBuilder<List<EmailThread>>( child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeThreads( stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
widget.accountId,
widget.mailboxPath,
limit: _limit,
),
builder: (ctx, snap) { builder: (ctx, snap) {
if (!snap.hasData) { if (!snap.hasData) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -440,26 +339,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Future<void> _batchMoveToRole( Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
_clearSelection(); _clearSelection();
final mailbox = await ref
final mailbox = await resolveMailboxByRole( .read(mailboxRepositoryProvider)
context, .findMailboxByRole(widget.accountId, role);
ref.read(mailboxRepositoryProvider), if (!mounted) return;
widget.accountId, if (mailbox == null) {
widget.mailboxPath, ScaffoldMessenger.of(
role, context,
dialogTitle: dialogTitle, ).showSnackBar(SnackBar(content: Text(notFoundMessage)));
createFolderName: createFolderName, return;
); }
if (!mounted || mailbox == null) return;
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -485,42 +377,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
} }
Future<void> _batchArchive() => _batchMoveToRole( Future<void> _batchArchive() =>
'archive', _batchMoveToRole('archive', 'No archive folder found');
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return;
final query = _searchController.text.trim();
final remaining = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, query);
if (!mounted) return;
if (remaining.isEmpty) {
if (context.canPop()) {
context.pop();
} else {
_searchController.clear();
}
} else {
setState(() => _searchResults = remaining);
}
}
Future<void> _openSearchResultAndRefresh(String emailId) async {
await context.push(
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/emails/${Uri.encodeComponent(emailId)}',
);
await _refreshSearchAndPopIfEmpty();
}
Future<void> _batchDelete() async { Future<void> _batchDelete() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
final wasSearching = _searching;
_clearSelection(); _clearSelection();
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
@@ -547,32 +408,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
originalEmails: originalEmails, originalEmails: originalEmails,
); );
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
if (wasSearching && mounted) {
// Filter deleted emails out of the local results immediately.
// Calling searchEmails here would hit the IMAP server, which still has
// the emails because the delete is only enqueued — not yet applied.
final deletedIds = ids.toSet();
final remaining = (_searchResults ?? [])
.where((e) => !deletedIds.contains(e.id))
.toList();
if (remaining.isEmpty) {
if (context.canPop()) {
context.pop();
} else {
_searchController.clear();
}
} else {
setState(() => _searchResults = remaining);
}
}
} }
Future<void> _batchMarkSpam() => _batchMoveToRole( Future<void> _batchMarkSpam() =>
'junk', _batchMoveToRole('junk', 'No spam folder found');
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMove() async { Future<void> _batchMove() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
@@ -668,7 +507,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
duration: const Duration(seconds: 5),
content: Text( content: Text(
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}', 'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
), ),
@@ -677,16 +515,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
Widget _buildThreadList(List<EmailThread> threads) { Widget _buildThreadList(List<EmailThread> threads) {
final hasMore = threads.length == _limit;
return ListView.builder( return ListView.builder(
itemCount: threads.length + (hasMore ? 1 : 0), itemCount: threads.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
final t = threads[i]; final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId); final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames = final senderNames =
@@ -755,7 +586,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
const Icon(Icons.star, color: Colors.amber, size: 16), const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_fmtDate(t.latestDate), _dateFmt.format(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall, style: Theme.of(ctx).textTheme.bodySmall,
), ),
], ],
@@ -857,9 +688,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final e = emails[i]; final e = emails[i];
final isSelected = _selectedSearchIds.contains(e.id); final isSelected = _selectedSearchIds.contains(e.id);
return EmailTile( final sender = e.from.isNotEmpty
email: e, ? (e.from.first.name ?? e.from.first.email)
selected: isSelected, : '(unknown)';
return ListTile(
leading: SizedBox( leading: SizedBox(
width: 40, width: 40,
child: _selecting child: _selecting
@@ -867,11 +699,31 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
value: isSelected, value: isSelected,
onChanged: (_) => _toggleSearchSelection(e.id), onChanged: (_) => _toggleSearchSelection(e.id),
) )
: null, : Icon(
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
),
),
title: Text(
sender,
style:
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
selected: isSelected,
trailing: Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
), ),
onTap: _selecting onTap: _selecting
? () => _toggleSearchSelection(e.id) ? () => _toggleSearchSelection(e.id)
: () => unawaited(_openSearchResultAndRefresh(e.id)), : () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
),
onLongPress: () => _toggleSearchSelection(e.id), onLongPress: () => _toggleSearchSelection(e.id),
); );
}, },
-20
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider); final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider); final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId)); final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'), title: const Text('Folders'),
actions: [ actions: [
IconButton( IconButton(
@@ -47,21 +42,6 @@ class MailboxListScreen extends ConsumerWidget {
], ],
), ),
drawer: FolderDrawer(accountId: accountId), drawer: FolderDrawer(accountId: accountId),
bottomNavigationBar: menuAtBottom
? BottomAppBar(
child: Row(
children: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
),
],
),
)
: null,
body: Column( body: Column(
children: [ children: [
// ── Failed-mutation banner ─────────────────────────────────────── // ── Failed-mutation banner ───────────────────────────────────────
+43 -106
View File
@@ -8,18 +8,6 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref,
) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
});
/// Returns true if [text] contains a word that starts with [query].
/// "foo" matches "foobar" or "My Foobar" but NOT "blafoo".
bool _hasWordPrefix(String text, String query) =>
RegExp(r'\b' + RegExp.escape(query), caseSensitive: false).hasMatch(text);
class SearchScreen extends ConsumerStatefulWidget { class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key, this.accountId}); const SearchScreen({super.key, this.accountId});
@@ -31,24 +19,13 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> { class _SearchScreenState extends ConsumerState<SearchScreen> {
final _ctrl = TextEditingController(); final _ctrl = TextEditingController();
final _focusNode = FocusNode();
Timer? _debounce; Timer? _debounce;
_SearchResults? _results; _SearchResults? _results;
bool _loading = false; bool _loading = false;
bool _fieldFocused = false;
@override
void initState() {
super.initState();
_focusNode.addListener(() {
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
});
}
@override @override
void dispose() { void dispose() {
_ctrl.dispose(); _ctrl.dispose();
_focusNode.dispose();
_debounce?.cancel(); _debounce?.cancel();
super.dispose(); super.dispose();
} }
@@ -67,12 +44,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Future<void> _search(String query) async { Future<void> _search(String query) async {
setState(() => _loading = true); setState(() => _loading = true);
unawaited(
ref
.read(searchHistoryRepositoryProvider)
.saveSearch(query)
.then((_) => ref.invalidate(_searchHistoryProvider)),
);
try { try {
final emailRepo = ref.read(emailRepositoryProvider); final emailRepo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
@@ -85,7 +56,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
).wait; ).wait;
final matchedMailboxes = allMailboxes final matchedMailboxes = allMailboxes
.where((m) => _hasWordPrefix(m.name, ql)) .where((m) => m.name.toLowerCase().contains(ql))
.toList() .toList()
..sort(compareMailboxes); ..sort(compareMailboxes);
@@ -97,9 +68,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
for (final addr in [...email.from, ...email.to, ...email.cc]) { for (final addr in [...email.from, ...email.to, ...email.cc]) {
final key = '${email.accountId}:${addr.email}'; final key = '${email.accountId}:${addr.email}';
if (seen.contains(key)) continue; if (seen.contains(key)) continue;
final matchesEmail = _hasWordPrefix(addr.email, ql); final matchesEmail = addr.email.toLowerCase().contains(ql);
final matchesName = final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
addr.name != null && _hasWordPrefix(addr.name!, ql);
if (!matchesEmail && !matchesName) continue; if (!matchesEmail && !matchesName) continue;
seen.add(key); seen.add(key);
final addrEmail = addr.email; final addrEmail = addr.email;
@@ -141,7 +111,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
appBar: AppBar( appBar: AppBar(
title: TextField( title: TextField(
controller: _ctrl, controller: _ctrl,
focusNode: _focusNode,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…', hintText: 'Search folders, addresses, emails…',
@@ -167,9 +136,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Widget _buildBody() { Widget _buildBody() {
if (_loading) return const Center(child: CircularProgressIndicator()); if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) { if (_results == null) {
if (_fieldFocused && _ctrl.text.isEmpty) {
return _buildHistoryPanel();
}
return const Center(child: Text('Type 3+ characters to search')); return const Center(child: Text('Type 3+ characters to search'));
} }
final r = _results!; final r = _results!;
@@ -189,79 +155,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[ if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'), const _SectionHeader('Messages'),
for (final e in r.emails) for (final e in r.emails)
EmailTile( _EmailTile(email: e, accountId: e.accountId),
email: e,
showLocation: true,
onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}'
'/emails/${Uri.encodeComponent(e.id)}',
),
),
], ],
], ],
); );
} }
Widget _buildHistoryPanel() {
final history = ref.watch(_searchHistoryProvider);
return history.when(
loading: () => const Center(child: Text('Type 3+ characters to search')),
error: (_, __) =>
const Center(child: Text('Type 3+ characters to search')),
data: (terms) {
if (terms.isEmpty) {
return const Center(child: Text('Type 3+ characters to search'));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Recent searches',
style: Theme.of(context).textTheme.labelLarge,
),
TextButton(
onPressed: () async {
await ref
.read(searchHistoryRepositoryProvider)
.clearHistory();
ref.invalidate(_searchHistoryProvider);
},
child: const Text('Clear'),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final term in terms)
ActionChip(
label: Text(term),
onPressed: () {
_ctrl.text = term;
_ctrl.selection = TextSelection.fromPosition(
TextPosition(offset: term.length),
);
unawaited(_search(term));
},
),
],
),
),
],
);
},
);
}
} }
class _SearchResults { class _SearchResults {
@@ -348,3 +246,42 @@ class _AddressTile extends StatelessWidget {
); );
} }
} }
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email, required this.accountId});
final Email email;
final String accountId;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(unknown)';
return ListTile(
leading: Icon(
email.isSeen ? Icons.mail_outline : Icons.mail,
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
),
title: Text(sender),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
email.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'$accountId${email.mailboxPath}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
onTap: () => context.push(
'/accounts/$accountId/mailboxes'
'/${Uri.encodeComponent(email.mailboxPath)}'
'/emails/${Uri.encodeComponent(email.id)}',
),
);
}
}
+9 -26
View File
@@ -11,7 +11,6 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
super.key, super.key,
required this.accountId, required this.accountId,
this.script, this.script,
this.isLocal = false,
}); });
final String accountId; final String accountId;
@@ -19,9 +18,6 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
/// Null when creating a new script. /// Null when creating a new script.
final SieveScript? script; final SieveScript? script;
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
final bool isLocal;
@override @override
ConsumerState<SieveScriptEditScreen> createState() => ConsumerState<SieveScriptEditScreen> createState() =>
_SieveScriptEditScreenState(); _SieveScriptEditScreenState();
@@ -54,13 +50,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
Future<void> _loadContent() async { Future<void> _loadContent() async {
setState(() => _loadingContent = true); setState(() => _loadingContent = true);
try { try {
final content = widget.isLocal final content = await ref
? await ref .read(sieveRepositoryProvider)
.read(localSieveRepositoryProvider) .getScriptContent(widget.accountId, widget.script!.blobId);
.getScriptContent(widget.accountId, widget.script!.blobId)
: await ref
.read(sieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) { if (mounted) {
_contentController.text = content; _contentController.text = content;
setState(() => _loadingContent = false); setState(() => _loadingContent = false);
@@ -86,21 +78,12 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
_error = null; _error = null;
}); });
try { try {
if (widget.isLocal) { await ref.read(sieveRepositoryProvider).saveScript(
await ref.read(localSieveRepositoryProvider).saveScript( widget.accountId,
widget.accountId, id: widget.script?.id,
id: widget.script?.id, name: name,
name: name, content: _contentController.text,
content: _contentController.text, );
);
} else {
await ref.read(sieveRepositoryProvider).saveScript(
widget.accountId,
id: widget.script?.id,
name: name,
content: _contentController.text,
);
}
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
+37 -119
View File
@@ -8,17 +8,10 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
class SieveScriptsScreen extends ConsumerStatefulWidget { class SieveScriptsScreen extends ConsumerStatefulWidget {
const SieveScriptsScreen({ const SieveScriptsScreen({super.key, required this.accountId});
super.key,
required this.accountId,
this.isLocal = false,
});
final String accountId; final String accountId;
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
final bool isLocal;
@override @override
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState(); ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
} }
@@ -28,10 +21,6 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
String? _error; String? _error;
bool _loading = true; bool _loading = true;
String get _editRoute => widget.isLocal
? '/accounts/${widget.accountId}/sieve/local/edit'
: '/accounts/${widget.accountId}/sieve/edit';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -44,13 +33,8 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
_error = null; _error = null;
}); });
try { try {
final scripts = widget.isLocal final scripts =
? await ref await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
.read(localSieveRepositoryProvider)
.listScripts(widget.accountId)
: await ref
.read(sieveRepositoryProvider)
.listScripts(widget.accountId);
if (mounted) { if (mounted) {
setState(() { setState(() {
_scripts = scripts; _scripts = scripts;
@@ -69,24 +53,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Future<void> _activate(SieveScript script) async { Future<void> _activate(SieveScript script) async {
try { try {
if (widget.isLocal) { await ref
await ref .read(sieveRepositoryProvider)
.read(localSieveRepositoryProvider) .activateScript(widget.accountId, script.id);
.activateScript(widget.accountId, script.id);
} else {
await ref
.read(sieveRepositoryProvider)
.activateScript(widget.accountId, script.id);
}
await _load(); await _load();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar( context,
duration: const Duration(seconds: 5), ).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
content: Text('Failed to activate: $e'),
),
);
} }
} }
} }
@@ -111,24 +86,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
); );
if (!(confirmed ?? false) || !mounted) return; if (!(confirmed ?? false) || !mounted) return;
try { try {
if (widget.isLocal) { await ref
await ref .read(sieveRepositoryProvider)
.read(localSieveRepositoryProvider) .deleteScript(widget.accountId, script.id);
.deleteScript(widget.accountId, script.id);
} else {
await ref
.read(sieveRepositoryProvider)
.deleteScript(widget.accountId, script.id);
}
await _load(); await _load();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar( context,
duration: const Duration(seconds: 5), ).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
content: Text('Failed to delete: $e'),
),
);
} }
} }
} }
@@ -136,13 +102,11 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Email filters')),
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
),
body: _buildBody(), body: _buildBody(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () async { onPressed: () async {
await context.push(_editRoute); await context.push('/accounts/${widget.accountId}/sieve/edit');
await _load(); await _load();
}, },
child: const Icon(Icons.add), child: const Icon(Icons.add),
@@ -170,69 +134,22 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
); );
} }
final scripts = _scripts ?? []; final scripts = _scripts ?? [];
return Column( if (scripts.isEmpty) {
children: [ return const Center(
_SieveSourceBanner(isLocal: widget.isLocal), child: Text('No Sieve scripts. Tap + to create one.'),
Expanded( );
child: scripts.isEmpty }
? const Center( return RefreshIndicator(
child: Text('No filters yet. Tap + to create one.'), onRefresh: _load,
) child: ListView.builder(
: RefreshIndicator( itemCount: scripts.length,
onRefresh: _load, itemBuilder: (ctx, i) => _ScriptTile(
child: ListView.builder( script: scripts[i],
itemCount: scripts.length, accountId: widget.accountId,
itemBuilder: (ctx, i) => _ScriptTile( onActivate: () => _activate(scripts[i]),
script: scripts[i], onDelete: () => _delete(scripts[i]),
accountId: widget.accountId, onEdited: _load,
editRoute: _editRoute,
onActivate: () => _activate(scripts[i]),
onDelete: () => _delete(scripts[i]),
onEdited: _load,
),
),
),
), ),
],
);
}
}
class _SieveSourceBanner extends StatelessWidget {
const _SieveSourceBanner({required this.isLocal});
final bool isLocal;
@override
Widget build(BuildContext context) {
final text = isLocal
? 'Local Filters run Sieve scripts directly on this device. '
'Remote Filters, which run on the mail server, are configured separately.'
: 'Remote Filters run Sieve scripts on the mail server '
'(ManageSieve or JMAP). '
'Local Filters, which run on this device, are configured separately.';
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isLocal ? Icons.phone_android : Icons.dns,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
), ),
); );
} }
@@ -242,7 +159,6 @@ class _ScriptTile extends StatelessWidget {
const _ScriptTile({ const _ScriptTile({
required this.script, required this.script,
required this.accountId, required this.accountId,
required this.editRoute,
required this.onActivate, required this.onActivate,
required this.onDelete, required this.onDelete,
required this.onEdited, required this.onEdited,
@@ -250,7 +166,6 @@ class _ScriptTile extends StatelessWidget {
final SieveScript script; final SieveScript script;
final String accountId; final String accountId;
final String editRoute;
final VoidCallback onActivate; final VoidCallback onActivate;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onEdited; final VoidCallback onEdited;
@@ -268,7 +183,10 @@ class _ScriptTile extends StatelessWidget {
onSelected: (action) async { onSelected: (action) async {
switch (action) { switch (action) {
case _ScriptAction.edit: case _ScriptAction.edit:
await context.push(editRoute, extra: script); await context.push(
'/accounts/$accountId/sieve/edit',
extra: script,
);
onEdited(); onEdited();
case _ScriptAction.activate: case _ScriptAction.activate:
onActivate(); onActivate();
@@ -291,7 +209,7 @@ class _ScriptTile extends StatelessWidget {
], ],
), ),
onTap: () async { onTap: () async {
await context.push(editRoute, extra: script); await context.push('/accounts/$accountId/sieve/edit', extra: script);
onEdited(); onEdited();
}, },
); );

Some files were not shown because too many files have changed in this diff Show More