Compare commits
335
Commits
@@ -0,0 +1,18 @@
|
||||
# Dagger context ignore file.
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.dart_tool/
|
||||
coverage/
|
||||
linux/flutter/ephemeral/
|
||||
website/public/
|
||||
website/resources/
|
||||
.task/
|
||||
.fvm/
|
||||
|
||||
# Secrets
|
||||
.env*
|
||||
.envrc
|
||||
@@ -14,5 +14,7 @@ PATH_add .fvm/flutter_sdk/bin
|
||||
|
||||
PATH_add "$HOME/Android/Sdk/platform-tools"
|
||||
|
||||
export DAGGER_NO_NAG=1
|
||||
|
||||
# Load variables from .env
|
||||
dotenv_if_exists
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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 \
|
||||
stunnel4 \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 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
|
||||
+22
-24
@@ -8,35 +8,33 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
# Match the label of your self-hosted runner
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Enable Nix flakes
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Full Check Suite
|
||||
# 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
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task check-dagger
|
||||
|
||||
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
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
env:
|
||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task test-android-firebase
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Publish Android to Play Store
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-android
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task deploy-apk
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
build-linux:
|
||||
name: Build Linux Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task deploy-linux
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-android-firebase, deploy-playstore, build-linux]
|
||||
if: always() && vars.DEPLOY_HEALTH_ISSUE != ''
|
||||
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.test-android-firebase.result == 'success' && needs.deploy-playstore.result == 'success' && needs.build-linux.result == 'success' }}
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Install Android SDK (cached on runner between runs)
|
||||
run: |
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
if [ ! -d "$SDK/platforms/android-34" ]; then
|
||||
echo "Android SDK not found, installing..."
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
mkdir -p "$SDK/cmdline-tools"
|
||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
||||
[ -d "$SDK/cmdline-tools/cmdline-tools" ] && mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||
yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
|
||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "build-tools;34.0.0" "platforms;android-34"
|
||||
else
|
||||
echo "Android SDK cached, skipping install."
|
||||
fi
|
||||
|
||||
- name: Prepare Keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
||||
else
|
||||
echo "Error: ANDROID_KEYSTORE_BASE64 secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build & Deploy to Play Store
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
||||
run: nix develop --command task deploy-android-bundle
|
||||
@@ -12,36 +12,21 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build & Deploy Website
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Enable Nix flakes
|
||||
run: |
|
||||
mkdir -p ~/.config/nix
|
||||
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
|
||||
|
||||
- name: Setup SSH
|
||||
- name: Build & Deploy Website
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEBSITE_SSH_PRIVATE_KEY }}
|
||||
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
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: task website-deploy
|
||||
|
||||
- name: Deploy
|
||||
- name: Verify Website
|
||||
env:
|
||||
SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: nix develop --command task website-deploy
|
||||
|
||||
- name: Verify
|
||||
run: nix develop --command task website-verify
|
||||
run: scripts/website-verify.sh
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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
|
||||
continue-on-error: true
|
||||
|
||||
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'
|
||||
continue-on-error: 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'
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: task deploy-windows-to-server
|
||||
+100
-4
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
analyze-and-test:
|
||||
name: Analyze & unit test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
integration:
|
||||
name: Integration tests (Stalwart)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
# Run integration tests only on push to main, not on every PR.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
integration-ui:
|
||||
name: UI Integration tests (Stalwart + Xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
build-linux:
|
||||
name: Build Linux desktop
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: sharedinbox-runner
|
||||
needs: analyze-and-test
|
||||
|
||||
steps:
|
||||
@@ -151,3 +151,99 @@ jobs:
|
||||
|
||||
- name: Build Linux release
|
||||
run: flutter build linux --release
|
||||
|
||||
deploy:
|
||||
name: Deploy Linux build & publish website
|
||||
runs-on: sharedinbox-runner
|
||||
needs: build-linux
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install build & deploy 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 hugo rsync
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.41.6"
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Cache pub packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate Drift code
|
||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
mkdir -p assets
|
||||
git log -n 50 \
|
||||
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
|
||||
--date=short > assets/changelog.txt
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
- name: Build Linux release
|
||||
run: |
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
|
||||
|
||||
- name: Deploy Linux build to server
|
||||
run: |
|
||||
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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
|
||||
- name: Generate build history pages
|
||||
run: python3 scripts/generate_build_history.py
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
|
||||
run: hugo --source website --minify
|
||||
|
||||
- name: Deploy website
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
"$SSH_USER@$SSH_HOST:public_html/"
|
||||
|
||||
+16
-1
@@ -3,7 +3,6 @@ coverage/
|
||||
.dart_tool/
|
||||
.dart-tool/
|
||||
.packages
|
||||
pubspec.lock
|
||||
build/
|
||||
*.g.dart
|
||||
*.freezed.dart
|
||||
@@ -58,6 +57,10 @@ linux/flutter/generated_plugins.cmake
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# --- Tools & Cache ---
|
||||
.fvm/
|
||||
fvm/
|
||||
@@ -98,6 +101,8 @@ sharedinbox-runner/runner-data/
|
||||
website/public/
|
||||
website/resources/
|
||||
website/.hugo_build.lock
|
||||
website/content/builds/_index.md
|
||||
website/content/builds/[0-9]*/
|
||||
|
||||
.copilot/
|
||||
.dotnet/
|
||||
@@ -105,4 +110,14 @@ website/.hugo_build.lock
|
||||
.wget-hsts
|
||||
|
||||
tmp/
|
||||
test/widget/failures/
|
||||
.claude*
|
||||
|
||||
dagger-certs
|
||||
.Xauthority
|
||||
.sharedinbox-agent-state.json
|
||||
|
||||
.viminfo
|
||||
/go
|
||||
.last_deployed_sha
|
||||
.fail_count
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "website/themes/PaperMod"]
|
||||
path = website/themes/PaperMod
|
||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
||||
@@ -12,6 +12,12 @@ repos:
|
||||
|
||||
- repo: local
|
||||
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
|
||||
name: check for forbidden home-directory files
|
||||
language: system
|
||||
@@ -24,3 +30,15 @@ repos:
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||
pass_filenames: false
|
||||
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 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 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
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# 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
|
||||
|
||||
We use issues, follow this label state machine:
|
||||
|
||||
- **State/Ready** — Issue is available to pick up
|
||||
- **State/InProgress** — Set this when you start working on an issue
|
||||
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||
|
||||
List open issues ready to pick up:
|
||||
|
||||
```bash
|
||||
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start work on an issue without `State/Ready`
|
||||
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||
by `agent_loop.py` before the agent starts — do **not** set it yourself.
|
||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||
```bash
|
||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||
```
|
||||
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||
- When done and CI is green, close the issue:
|
||||
```bash
|
||||
fgj issue close <NUMBER>
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
- Avoid `else`, use "early return".
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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.11.4#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.
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Implementation Plan: Secure WebView for HTML Emails (#21)
|
||||
|
||||
## Goal
|
||||
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
|
||||
|
||||
## 1. Dependency Management
|
||||
- **Core**: `webview_flutter` (v4+)
|
||||
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
|
||||
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
|
||||
|
||||
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
|
||||
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
|
||||
|
||||
### Configuration & Hardening
|
||||
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
|
||||
- **Background**: Match the application theme (e.g., transparent or surface color).
|
||||
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
|
||||
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
|
||||
|
||||
### Image Blocking Logic
|
||||
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
|
||||
- **Toggle Mechanism**:
|
||||
- Provide a "Load Remote Images" button in the Flutter UI.
|
||||
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
|
||||
|
||||
### Link Interception & Phishing Protection
|
||||
- Implement `NavigationDelegate.onNavigationRequest`.
|
||||
- **Process**:
|
||||
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
|
||||
2. Block the navigation in the WebView.
|
||||
3. Trigger a Flutter `showDialog` for confirmation.
|
||||
- **Phishing Protection Dialog**:
|
||||
- Show the full URL.
|
||||
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
|
||||
- Example: `https://`**`important-bank.com`**`/login`
|
||||
- "Open in Browser" button uses `url_launcher`.
|
||||
|
||||
## 3. Integration Plan
|
||||
### Step 1: Initialization
|
||||
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
|
||||
|
||||
### Step 2: Replace Renderer in Screens
|
||||
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
||||
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
||||
- Remove `flutter_html` imports and dependencies once migration is complete.
|
||||
|
||||
## 4. Verification & Security Audit
|
||||
- **Manual Tests**:
|
||||
- Open emails with complex HTML layouts.
|
||||
- Verify images are blocked initially.
|
||||
- Verify "Load images" works.
|
||||
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
|
||||
- **Security Check**:
|
||||
- Verify that `<script>` tags are not executed.
|
||||
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
|
||||
|
||||
## 5. Potential Challenges
|
||||
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
|
||||
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
|
||||
@@ -0,0 +1,206 @@
|
||||
# Email Sync Architecture
|
||||
|
||||
This document describes the full lifecycle of an email action — from the moment the user taps
|
||||
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
|
||||
queue, exponential backoff, and the undo/cancel mechanism.
|
||||
|
||||
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Components
|
||||
|
||||
| Component | File | Role |
|
||||
|-----------|------|------|
|
||||
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
|
||||
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
|
||||
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
|
||||
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
|
||||
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
|
||||
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
|
||||
|
||||
---
|
||||
|
||||
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
|
||||
|
||||
```
|
||||
User taps "Mark as read"
|
||||
│
|
||||
▼
|
||||
EmailRepository.setFlag(id, seen: true)
|
||||
│
|
||||
├─ 1. Write optimistic update to local DB
|
||||
│ emails.is_seen = true
|
||||
│
|
||||
└─ 2. Insert row into pending_changes
|
||||
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
|
||||
(IMAP: includes uid + mailboxPath for the STORE command)
|
||||
(JMAP: includes just the flag map for Email/set)
|
||||
|
||||
[UI immediately reflects the change via Drift's reactive streams]
|
||||
|
||||
│
|
||||
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
|
||||
_SyncLoop._flush() / flushPendingChanges()
|
||||
│
|
||||
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
|
||||
│
|
||||
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
|
||||
If stateMismatch → clear checkpoint → full re-sync
|
||||
|
||||
│
|
||||
▼
|
||||
pending_changes row deleted on success
|
||||
(on permanent error: retry count incremented; evicted after 5 failures)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. IMAP sync loop
|
||||
|
||||
The IMAP loop runs one coroutine per account (`_AccountSync`):
|
||||
|
||||
```
|
||||
start()
|
||||
│
|
||||
▼
|
||||
[forever loop]
|
||||
├─ flushPendingChanges() ← drain outbound queue first
|
||||
├─ syncMailboxes() ← detect new/removed mailboxes
|
||||
├─ for each mailbox:
|
||||
│ syncEmails() ← incremental: fetch only UIDs > lastUid
|
||||
│ deletion reconciliation: remove rows
|
||||
│ whose UID is absent from the server
|
||||
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
|
||||
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
|
||||
│ or syncNow() signal from UI
|
||||
└─ repeat
|
||||
```
|
||||
|
||||
**Incremental sync checkpoint** — `sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
|
||||
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
||||
folder is re-scanned and the checkpoint is reset.
|
||||
|
||||
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
|
||||
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
||||
|
||||
---
|
||||
|
||||
## 4. JMAP sync loop
|
||||
|
||||
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
|
||||
|
||||
```
|
||||
start()
|
||||
│
|
||||
▼
|
||||
[forever loop]
|
||||
├─ flushPendingChanges() ← Email/set for queued mutations
|
||||
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
|
||||
├─ for each mailbox:
|
||||
│ syncEmails() ← Email/query + Email/get (first run)
|
||||
│ Email/changes (subsequent runs, state token)
|
||||
└─ _wait()
|
||||
├─ If server advertises eventSourceUrl: subscribe to SSE push
|
||||
│ wake on "Email" change event
|
||||
└─ Otherwise: sleep 30 s (poll fallback)
|
||||
```
|
||||
|
||||
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
|
||||
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
|
||||
a full re-fetch.
|
||||
|
||||
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
|
||||
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exponential backoff
|
||||
|
||||
Both loops share the same backoff policy:
|
||||
|
||||
| Outcome | Backoff |
|
||||
|---------|---------|
|
||||
| Sync succeeded | Reset to 5 s |
|
||||
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
|
||||
|
||||
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
|
||||
on the next successful cycle.
|
||||
|
||||
The last error message is written to `sync_log` and surfaced in the UI via
|
||||
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
|
||||
|
||||
---
|
||||
|
||||
## 6. Pending-change queue
|
||||
|
||||
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
|
||||
reaches the server:
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| `id` | Auto-increment primary key |
|
||||
| `email_id` | The email being mutated |
|
||||
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
|
||||
| `payload` | JSON-encoded protocol-specific arguments |
|
||||
| `retry_count` | Incremented on each failed flush attempt |
|
||||
| `created_at` | For ordering and debug |
|
||||
|
||||
**Optimistic UI** — every mutation writes the local change first, then inserts into
|
||||
`pending_changes`. The Drift reactive stream delivers the update to the UI before
|
||||
the network round-trip completes.
|
||||
|
||||
**Conflict resolution** — the server always wins. On the next sync cycle the server's
|
||||
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
|
||||
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
|
||||
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
|
||||
|
||||
---
|
||||
|
||||
## 7. Undo and cancel
|
||||
|
||||
When the user triggers an undoable action the UI calls:
|
||||
```
|
||||
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
|
||||
```
|
||||
|
||||
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
|
||||
A `SnackBar` with an **Undo** button appears for a few seconds.
|
||||
|
||||
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
|
||||
|
||||
```
|
||||
1. cancelPendingChange(id, originalType)
|
||||
└─ Deletes the pending_changes row if it has not been flushed yet.
|
||||
Returns true if cancelled, false if the server already processed it.
|
||||
|
||||
2. If the email row was hard-deleted (DELETE action):
|
||||
restoreEmails([original])
|
||||
└─ Re-inserts the row with its pre-deletion state,
|
||||
placed in the correct mailbox (source if cancelled, dest otherwise).
|
||||
|
||||
3. moveEmail(id, sourceMailboxPath)
|
||||
└─ Optimistic local move back to the original folder.
|
||||
If step 1 returned false (already sent to server), this enqueues
|
||||
a reverse-move in pending_changes so the server move is undone too.
|
||||
|
||||
4. If step 1 returned true (cancelled before flush):
|
||||
cancelPendingChange(id, 'move')
|
||||
└─ The reverse-move from step 3 is redundant; remove it.
|
||||
```
|
||||
|
||||
The net result is: if the mutation was still in the queue it is silently cancelled with no
|
||||
server round-trip; if it had already been flushed, a compensating move is queued.
|
||||
|
||||
---
|
||||
|
||||
## 8. Key invariants
|
||||
|
||||
- **Order**: pending changes are flushed before syncing. This prevents the server from
|
||||
overwriting an optimistic local state that the server hasn't seen yet.
|
||||
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
|
||||
deleted only after the server acknowledges the change.
|
||||
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
|
||||
visible in the UI (Settings → Failed mutations).
|
||||
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
|
||||
through `core/` interfaces. The `check-layers` Taskfile task enforces this.
|
||||
+294
-23
@@ -1,6 +1,9 @@
|
||||
version: "3"
|
||||
silent: true
|
||||
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
|
||||
@@ -122,6 +125,16 @@ tasks:
|
||||
cmds:
|
||||
- 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:
|
||||
desc: Auto-fix lint issues with dart fix --apply
|
||||
deps: [_preflight]
|
||||
@@ -161,23 +174,149 @@ tasks:
|
||||
cmds:
|
||||
- fvm flutter test
|
||||
|
||||
integration:
|
||||
desc: Integration tests against a local Stalwart mail server
|
||||
deps: [_flutter-check]
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- test/integration/**/*.dart
|
||||
test-backend:
|
||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||
cmds:
|
||||
- stalwart-dev/test.sh
|
||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||
|
||||
integration-ui:
|
||||
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
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||
cmds:
|
||||
- stalwart-dev/integration_ui_test.sh
|
||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||
|
||||
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"
|
||||
cmds:
|
||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --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
|
||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -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
|
||||
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:
|
||||
- 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
|
||||
|
||||
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 "$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) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --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"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
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 canceled|connection refused" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||
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; 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
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
@@ -216,7 +355,79 @@ tasks:
|
||||
generates:
|
||||
- build/linux/x64/release/bundle/sharedinbox
|
||||
cmds:
|
||||
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub
|
||||
- scripts/silent_on_success.sh fvm flutter build linux --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD)
|
||||
|
||||
deploy-linux-to-server:
|
||||
desc: Package and deploy the Linux release bundle to the server, update latest.json
|
||||
deps: [build-linux-release]
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
- sh: test -n "$SSH_HOST"
|
||||
msg: "SSH_HOST is not set"
|
||||
cmds:
|
||||
- |
|
||||
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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /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 -o StrictHostKeyChecking=no "$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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $TARBALL and updated latest.json"
|
||||
|
||||
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"
|
||||
cmds:
|
||||
- |
|
||||
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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||
|
||||
|
||||
_android-avd-setup:
|
||||
@@ -266,19 +477,19 @@ tasks:
|
||||
generates:
|
||||
- build/app/outputs/flutter-apk/app-release.apk
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track
|
||||
deps: [build-android-bundle]
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
cmds:
|
||||
- python3 scripts/deploy_playstore.py
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build a release App Bundle (AAB)
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
method: timestamp
|
||||
sources:
|
||||
@@ -288,7 +499,7 @@ tasks:
|
||||
generates:
|
||||
- build/app/outputs/bundle/release/app-release.aab
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
- 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"
|
||||
|
||||
deploy-android:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
@@ -331,6 +542,12 @@ tasks:
|
||||
cmds:
|
||||
- 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:
|
||||
desc: Run Hugo development server
|
||||
cmds:
|
||||
@@ -350,19 +567,73 @@ tasks:
|
||||
cmds:
|
||||
- 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"
|
||||
cmds:
|
||||
- |
|
||||
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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
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:
|
||||
desc: Deploy the website via rsync to public_html
|
||||
deps: [website-build]
|
||||
cmds:
|
||||
- |
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
${SSH_USER}@${SSH_HOST}:public_html/
|
||||
|
||||
check-fast:
|
||||
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
|
||||
deps: [analyze, test, check-hygiene]
|
||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||
|
||||
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:
|
||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||
@@ -382,7 +653,7 @@ tasks:
|
||||
internal: true
|
||||
run: once
|
||||
cmds:
|
||||
- task: integration
|
||||
- task: test-backend
|
||||
- task: integration-ui
|
||||
|
||||
ci-logs:
|
||||
|
||||
@@ -13,6 +13,7 @@ android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
@@ -35,7 +36,7 @@ android {
|
||||
applicationId = "de.sharedinbox.mua"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
minSdk = 23
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@@ -65,6 +66,8 @@ flutter {
|
||||
}
|
||||
|
||||
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.4")
|
||||
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
||||
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
||||
// references its class in all variants. Make it available for release compilation
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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
|
||||
android:label="sharedinbox"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
org.gradle.welcome=never
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/dagger.gen.go linguist-generated
|
||||
/internal/dagger/** linguist-generated
|
||||
/internal/querybuilder/** linguist-generated
|
||||
/internal/telemetry/** linguist-generated
|
||||
@@ -0,0 +1,5 @@
|
||||
/dagger.gen.go
|
||||
/internal/dagger
|
||||
/internal/querybuilder
|
||||
/internal/telemetry
|
||||
/.env
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "ci",
|
||||
"engineVersion": "v0.20.8",
|
||||
"sdk": {
|
||||
"source": "go"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
module dagger/ci
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
||||
github.com/Khan/genqlient v0.8.1
|
||||
github.com/dagger/otel-go v1.43.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.33
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.90 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/sosodev/duration v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
|
||||
|
||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
|
||||
|
||||
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
|
||||
|
||||
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
|
||||
@@ -0,0 +1,97 @@
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
||||
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
||||
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
||||
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+848
@@ -0,0 +1,848 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"dagger/ci/internal/dagger"
|
||||
"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
|
||||
}
|
||||
|
||||
func New(
|
||||
// +defaultPath=".."
|
||||
source *dagger.Directory,
|
||||
) *Ci {
|
||||
return &Ci{
|
||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{
|
||||
"lib/",
|
||||
"test/",
|
||||
"assets/",
|
||||
"scripts/",
|
||||
"pubspec.yaml",
|
||||
"pubspec.lock",
|
||||
"analysis_options.yaml",
|
||||
"linux/",
|
||||
"android/",
|
||||
"integration_test/",
|
||||
"drift_schemas/",
|
||||
"stalwart-dev/",
|
||||
"website/",
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// 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:3.41.6").
|
||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||
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; }`})
|
||||
}
|
||||
|
||||
// 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 '^[+~><] ' "$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 '^\[' "$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/"},
|
||||
})
|
||||
}
|
||||
|
||||
// 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{"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) *dagger.Container {
|
||||
return dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -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)
|
||||
}
|
||||
|
||||
// CheckMocks verifies that generated mocks are up to date.
|
||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
// comparing two freshly-generated outputs.
|
||||
func (m *Ci) CheckMocks(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 '^\[' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Coverage runs unit 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 --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()
|
||||
|
||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||
return "Hygiene check failed", err
|
||||
}
|
||||
if _, err := m.CheckLayers(ctx); err != nil {
|
||||
return "Layer check failed", err
|
||||
}
|
||||
|
||||
checkSetup := m.setup(m.checkSrc())
|
||||
|
||||
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.CheckMocks(ctx)
|
||||
if err != nil {
|
||||
return mocks, err
|
||||
}
|
||||
|
||||
coverage, err := m.Coverage(ctx)
|
||||
if err != nil {
|
||||
return coverage, err
|
||||
}
|
||||
|
||||
var testBackend, testIntegration string
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testBackend, e = m.TestBackend(egCtx)
|
||||
return e
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testIntegration, e = m.TestIntegration(egCtx)
|
||||
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,
|
||||
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}).
|
||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
||||
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,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
||||
|
||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"website/"},
|
||||
}).WithDirectory("website/content/builds", buildHistory)
|
||||
|
||||
return m.Hugo().
|
||||
WithDirectory("/src", websiteSource).
|
||||
WithWorkdir("/src/website").
|
||||
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,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) (string, error) {
|
||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
||||
|
||||
return m.Deployer(sshKey).
|
||||
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() *dagger.Directory {
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
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,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
bundle := m.BuildLinuxRelease()
|
||||
|
||||
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).
|
||||
WithDirectory("/bundle", bundle).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-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 -o StrictHostKeyChecking=no -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.setup(m.androidSrc()).
|
||||
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) *dagger.File {
|
||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||
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,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
) (string, error) {
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||
|
||||
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).
|
||||
WithFile("/tmp/app.apk", apk).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-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 -o StrictHostKeyChecking=no -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.setup(m.firebaseSrc()).
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
WithExec([]string{"./gradlew", "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() *dagger.File {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||
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", "requests", "google-auth"}).
|
||||
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,
|
||||
) (string, error) {
|
||||
versionCode := int(time.Now().Unix())
|
||||
aab := m.BuildAndroidRelease()
|
||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||
}
|
||||
|
||||
// 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 `# CI Pipeline Graph
|
||||
|
||||
` + "```" + `mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||
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["CheckMocks\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 ["Codeberg CI · .forgejo/workflows/ci.yml"]
|
||||
ciCheck["check"]
|
||||
buildLinux["build-linux\n(main only)"]
|
||||
deployPS["deploy-playstore\n(main only)"]
|
||||
pubWeb["publish-website\n(main only)"]
|
||||
|
||||
ciCheck --> buildLinux
|
||||
ciCheck --> deployPS
|
||||
buildLinux --> pubWeb
|
||||
deployPS --> pubWeb
|
||||
end
|
||||
|
||||
check -- "task check-dagger" --> ciCheck
|
||||
` + "```"
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal OTLP HTTP/protobuf trace receiver for Dagger CI timing.
|
||||
|
||||
Usage:
|
||||
python3 ci/otel-receiver.py --port-file=/tmp/otel.port
|
||||
|
||||
Caller sets:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
# ── Minimal protobuf binary decoder ─────────────────────────────────────────
|
||||
# Only decodes the fields we need; skips everything else safely.
|
||||
|
||||
def _varint(buf, pos):
|
||||
n, shift = 0, 0
|
||||
while pos < len(buf):
|
||||
b = buf[pos]; pos += 1
|
||||
n |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not (b & 0x80):
|
||||
return n, pos
|
||||
raise ValueError("truncated varint")
|
||||
|
||||
|
||||
def _fields(buf):
|
||||
"""Yield (field_num, wire_type, raw_value) for each field in a message."""
|
||||
pos = 0
|
||||
while pos < len(buf):
|
||||
tag, pos = _varint(buf, pos)
|
||||
wt, fn = tag & 7, tag >> 3
|
||||
if wt == 0: # varint
|
||||
v, pos = _varint(buf, pos)
|
||||
elif wt == 1: # fixed64
|
||||
v = struct.unpack_from("<Q", buf, pos)[0]; pos += 8
|
||||
elif wt == 2: # length-delimited
|
||||
n, pos = _varint(buf, pos)
|
||||
v = buf[pos:pos + n]; pos += n
|
||||
elif wt == 5: # fixed32
|
||||
v = struct.unpack_from("<I", buf, pos)[0]; pos += 4
|
||||
else:
|
||||
break # unknown: stop
|
||||
yield fn, wt, v
|
||||
|
||||
|
||||
def _any_value(buf):
|
||||
"""Parse AnyValue, return (type_tag, python_value)."""
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 1 and wt == 2: # string_value
|
||||
return "str", v.decode("utf-8", errors="replace")
|
||||
if fn == 2 and wt == 0: # bool_value
|
||||
return "bool", bool(v)
|
||||
if fn == 3 and wt == 0: # int_value (sint64)
|
||||
return "int", v
|
||||
if fn == 4 and wt == 1: # double_value
|
||||
return "float", struct.unpack("<d", struct.pack("<Q", v))[0]
|
||||
return None, None
|
||||
|
||||
|
||||
def _keyvalue(buf):
|
||||
key, tag, val = None, None, None
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 1 and wt == 2:
|
||||
key = v.decode("utf-8", errors="replace")
|
||||
elif fn == 2 and wt == 2:
|
||||
tag, val = _any_value(v)
|
||||
return key, tag, val
|
||||
|
||||
|
||||
def _span(buf):
|
||||
name = ""
|
||||
start_ns = end_ns = 0
|
||||
cached = False
|
||||
for fn, wt, v in _fields(buf):
|
||||
if fn == 5 and wt == 2: # name
|
||||
name = v.decode("utf-8", errors="replace")
|
||||
elif fn == 7 and wt == 1: # start_time_unix_nano
|
||||
start_ns = v
|
||||
elif fn == 8 and wt == 1: # end_time_unix_nano
|
||||
end_ns = v
|
||||
elif fn == 9 and wt == 2: # attributes (repeated)
|
||||
k, tag, val = _keyvalue(v)
|
||||
if tag == "bool" and k and "cached" in k.lower():
|
||||
cached = val
|
||||
return {"name": name, "dur": max(0.0, (end_ns - start_ns) / 1e9), "cached": cached}
|
||||
|
||||
|
||||
def _decode(body):
|
||||
spans = []
|
||||
for fn1, wt1, rs in _fields(body): # resource_spans = 1
|
||||
if fn1 != 1 or wt1 != 2:
|
||||
continue
|
||||
for fn2, wt2, ss in _fields(rs): # scope_spans = 2
|
||||
if fn2 != 2 or wt2 != 2:
|
||||
continue
|
||||
for fn3, wt3, sp in _fields(ss): # spans = 2
|
||||
if fn3 == 2 and wt3 == 2:
|
||||
spans.append(_span(sp))
|
||||
return spans
|
||||
|
||||
|
||||
# ── HTTP receiver ────────────────────────────────────────────────────────────
|
||||
|
||||
_spans = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _respond(self, code, body=b""):
|
||||
self.close_connection = True # actually close after response, matching the header
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/x-protobuf")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path != "/shutdown":
|
||||
self._respond(404); return
|
||||
self._respond(200, b"shutting down")
|
||||
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/v1/traces":
|
||||
self._respond(404); return
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n)
|
||||
try:
|
||||
decoded = _decode(body)
|
||||
except Exception as exc:
|
||||
print(f"[otel-receiver] decode error: {exc}", file=sys.stderr, flush=True)
|
||||
self._respond(400, str(exc).encode()); return
|
||||
with _lock:
|
||||
_spans.extend(decoded)
|
||||
self._respond(200)
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
|
||||
# ── Timing report ────────────────────────────────────────────────────────────
|
||||
|
||||
def _report():
|
||||
with _lock:
|
||||
if not _spans:
|
||||
print("otel-receiver: no spans received", file=sys.stderr)
|
||||
return
|
||||
rows = sorted(_spans, key=lambda r: r["dur"], reverse=True)
|
||||
NAME_W = 38
|
||||
print(f'\n{"STATUS":<6} {"DURATION":>8} SPAN')
|
||||
print("─" * (6 + 2 + 8 + 2 + NAME_W + 20))
|
||||
for r in rows:
|
||||
status = "CACHED" if r["cached"] else "LIVE"
|
||||
name = r["name"]
|
||||
if len(name) > NAME_W:
|
||||
name = name[: NAME_W - 1] + "…"
|
||||
print(f'{status:<6} {r["dur"]:7.2f}s {name}')
|
||||
print(f"\n{len(rows)} spans total")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port-file", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
if args.port_file:
|
||||
with open(args.port_file, "w") as f:
|
||||
f.write(str(server.server_address[1]))
|
||||
|
||||
def _shutdown(sig, frame):
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
server.serve_forever()
|
||||
_report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,3 +3,48 @@
|
||||
Installed like explained here:
|
||||
|
||||
https://forgejo.org/docs/next/admin/actions/installation/binary/
|
||||
|
||||
## Connecting to Dagger (via stunnel)
|
||||
|
||||
Dagger is running on the host machine and exported via stunnel on port 8774. The runner connects to it using a local stunnel client.
|
||||
|
||||
The following TLS secrets must be configured as environment variables in Codeberg:
|
||||
- `DAGGER_CLIENT_CERT`: Content of `client.crt`
|
||||
- `DAGGER_CLIENT_KEY`: Content of `client.key`
|
||||
- `DAGGER_CA_CERT`: Content of `ca.crt`
|
||||
|
||||
### Setup Script
|
||||
|
||||
This snippet can be used in a CI job to establish the connection:
|
||||
|
||||
```bash
|
||||
# Write TLS files from environment variables
|
||||
mkdir -p /etc/dagger/tls
|
||||
echo "$DAGGER_CLIENT_CERT" > /etc/dagger/tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /etc/dagger/tls/client.key
|
||||
echo "$DAGGER_CA_CERT" > /etc/dagger/tls/ca.crt
|
||||
|
||||
# Create stunnel configuration
|
||||
cat > /tmp/dagger-client.conf << EOF
|
||||
foreground = yes
|
||||
pid =
|
||||
|
||||
[dagger]
|
||||
client = yes
|
||||
accept = 127.0.0.1:1774
|
||||
connect = <server-ip>:8774
|
||||
cert = /etc/dagger/tls/client.crt
|
||||
key = /etc/dagger/tls/client.key
|
||||
CAfile = /etc/dagger/tls/ca.crt
|
||||
verify = 2
|
||||
EOF
|
||||
|
||||
# Start stunnel in the background
|
||||
stunnel /tmp/dagger-client.conf &
|
||||
|
||||
# Configure Dagger to use the tunnel
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
dagger version
|
||||
```
|
||||
|
||||
Note: Replace `<server-ip>` with the actual IP address of the machine running Dagger.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/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"; 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"
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cron deploy script for sharedinbox website.
|
||||
Runs every 5 minutes; skips if origin/main has not changed since last successful deploy.
|
||||
Gives up and creates a Codeberg issue after 5 consecutive failures on the same commit.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_DIR = Path(__file__).parent.resolve()
|
||||
SHA_FILE = REPO_DIR / '.last_deployed_sha'
|
||||
FAILED_SHA_FILE = REPO_DIR / '.last_failed_sha'
|
||||
FAIL_COUNT_FILE = REPO_DIR / '.fail_count'
|
||||
ERROR_FILE = REPO_DIR / '.last_deploy_error'
|
||||
ISSUE_SHA_FILE = REPO_DIR / '.last_issue_sha'
|
||||
|
||||
MAX_FAILURES = 5
|
||||
REPO = 'guettli/sharedinbox'
|
||||
CODEBERG = 'https://codeberg.org'
|
||||
|
||||
|
||||
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 read_int(path: Path) -> int:
|
||||
try:
|
||||
return int(read(path))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def issue_exists_for(sha: str) -> bool:
|
||||
"""Check Codeberg for an open issue referencing this commit SHA."""
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'list', '--repo', REPO, '--state', 'open',
|
||||
'--limit', '50', '--output', 'simple'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return sha[:8] in result.stdout
|
||||
|
||||
|
||||
def create_issue(failed_sha: str, fail_count: int) -> None:
|
||||
error_output = read(ERROR_FILE)
|
||||
tail = '\n'.join(error_output.splitlines()[-40:]) if error_output else '(no output captured)'
|
||||
commit_url = f'{CODEBERG}/{REPO}/commit/{failed_sha}'
|
||||
script_url = f'{CODEBERG}/{REPO}/src/branch/main/deploy_cron.py'
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
||||
|
||||
title = f'Deploy failed {fail_count}x on {failed_sha[:8]} — needs fix'
|
||||
body = f"""\
|
||||
## Deploy failure — action needed
|
||||
|
||||
The automated deploy cron failed **{fail_count} times** on commit \
|
||||
[{failed_sha[:8]}]({commit_url}) and has stopped retrying.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Detected** | {timestamp} |
|
||||
| **Failing commit** | [{failed_sha}]({commit_url}) |
|
||||
| **Failures** | {fail_count} / {MAX_FAILURES} |
|
||||
| **Deploy script** | [deploy_cron.py]({script_url}) |
|
||||
| **Log file** | `~/si-deploy-cron/deploy.log` |
|
||||
|
||||
### Last deploy output
|
||||
|
||||
```
|
||||
{tail}
|
||||
```
|
||||
|
||||
### Next steps
|
||||
|
||||
Push a fix to `main` — the cron (every 5 min) will retry automatically on the next commit.
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
['tea', 'issue', 'create',
|
||||
'--repo', REPO,
|
||||
'--title', title,
|
||||
'--description', body,
|
||||
'--labels', 'State/Ready,Prio/High'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f'Failed to create issue: {result.stderr}', file=sys.stderr)
|
||||
else:
|
||||
print(f'Issue created: {result.stdout.strip()}')
|
||||
|
||||
|
||||
def main():
|
||||
git('fetch', 'origin', 'main')
|
||||
remote_sha = git('rev-parse', 'origin/main')
|
||||
|
||||
last_sha = read(SHA_FILE)
|
||||
last_failed = read(FAILED_SHA_FILE)
|
||||
fail_count = read_int(FAIL_COUNT_FILE) if remote_sha == last_failed else 0
|
||||
last_issue = read(ISSUE_SHA_FILE)
|
||||
|
||||
if remote_sha == last_sha:
|
||||
print(f'No changes since {remote_sha[:8]}, skipping.')
|
||||
return
|
||||
|
||||
if fail_count >= MAX_FAILURES:
|
||||
if remote_sha != last_issue and not issue_exists_for(remote_sha):
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x — creating issue.')
|
||||
create_issue(remote_sha, fail_count)
|
||||
ISSUE_SHA_FILE.write_text(remote_sha + '\n')
|
||||
else:
|
||||
print(f'{remote_sha[:8]} failed {fail_count}x, issue already exists, skipping.')
|
||||
return
|
||||
|
||||
attempt = fail_count + 1
|
||||
print(f'Deploying {remote_sha[:8]} (attempt {attempt}/{MAX_FAILURES}, was {last_sha[:8] or "none"})...')
|
||||
git('pull', '--ff-only', 'origin', 'main')
|
||||
|
||||
result = subprocess.run(
|
||||
['task', 'publish-website'],
|
||||
cwd=REPO_DIR,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
print(combined, end='')
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f'Deploy failed (exit {result.returncode}), attempt {attempt}/{MAX_FAILURES}', file=sys.stderr)
|
||||
FAILED_SHA_FILE.write_text(remote_sha + '\n')
|
||||
FAIL_COUNT_FILE.write_text(str(attempt) + '\n')
|
||||
ERROR_FILE.write_text(combined)
|
||||
sys.exit(1)
|
||||
|
||||
SHA_FILE.write_text(remote_sha + '\n')
|
||||
for f in (FAILED_SHA_FILE, FAIL_COUNT_FILE, ERROR_FILE, ISSUE_SHA_FILE):
|
||||
f.unlink(missing_ok=True)
|
||||
print('Deploy complete.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Generated
+24
-3
@@ -1,5 +1,25 @@
|
||||
{
|
||||
"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": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -20,11 +40,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778430510,
|
||||
"narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=",
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575",
|
||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,6 +56,7 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"dagger": "dagger",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
dagger.url = "github:dagger/nix";
|
||||
dagger.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
outputs = { self, nixpkgs, flake-utils, dagger }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# All Linux desktop runtime libraries needed by flutter build linux and
|
||||
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
||||
@@ -27,7 +29,11 @@
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
harfbuzz
|
||||
# Dagger remote setup dependencies
|
||||
stunnel
|
||||
netcat
|
||||
];
|
||||
|
||||
fgj = pkgs.stdenv.mkDerivation {
|
||||
pname = "fgj";
|
||||
version = "0.4.0";
|
||||
@@ -45,8 +51,13 @@
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger.packages.${system}.dagger
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
|
||||
# Java JDK — required by Gradle for Android builds
|
||||
jdk17
|
||||
|
||||
# Task runner
|
||||
go-task
|
||||
@@ -83,7 +94,8 @@
|
||||
sqlite
|
||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||
(python3.withPackages (ps: with ps; [
|
||||
google-api-python-client
|
||||
google-auth
|
||||
requests
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
]);
|
||||
|
||||
@@ -112,12 +112,28 @@ void main() {
|
||||
late String userPass;
|
||||
|
||||
setUpAll(() {
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430');
|
||||
smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025');
|
||||
userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
||||
userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
||||
const required = [
|
||||
'STALWART_IMAP_HOST',
|
||||
'STALWART_IMAP_PORT',
|
||||
'STALWART_SMTP_HOST',
|
||||
'STALWART_SMTP_PORT',
|
||||
'STALWART_USER_B',
|
||||
'STALWART_PASS_B',
|
||||
];
|
||||
final missing = required.where((k) => Platform.environment[k] == null).toList();
|
||||
if (missing.isNotEmpty) {
|
||||
fail(
|
||||
'Missing required environment variables: ${missing.join(', ')}. '
|
||||
'This test requires a running Stalwart instance — '
|
||||
'run via stalwart-dev/integration_ui_test.sh.',
|
||||
);
|
||||
}
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST']!;
|
||||
imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT']!);
|
||||
smtpHost = Platform.environment['STALWART_SMTP_HOST']!;
|
||||
smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT']!);
|
||||
userEmail = Platform.environment['STALWART_USER_B']!;
|
||||
userPass = Platform.environment['STALWART_PASS_B']!;
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -130,17 +146,12 @@ void main() {
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
// On Android, the keyboard-dismiss / window-resize cycle can trigger
|
||||
// one final layout pass on already-disposed render objects (DEFUNCT).
|
||||
// These spurious overflow errors have no effect on real functionality;
|
||||
// filter them so they don't fail the test.
|
||||
final prevError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
final msg = details.toString();
|
||||
if (msg.contains('DEFUNCT') || msg.contains('DISPOSED')) return;
|
||||
prevError?.call(details);
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = prevError);
|
||||
// Capture the test binding's error recorder and error-widget builder
|
||||
// BEFORE app.main() so teardown can restore both. app.main() overwrites
|
||||
// FlutterError.onError (crash-screen handler) and ErrorWidget.builder;
|
||||
// the test binding verifies both are unchanged after the test completes.
|
||||
final bindingError = FlutterError.onError;
|
||||
final bindingErrorWidgetBuilder = ErrorWidget.builder;
|
||||
|
||||
_log('app start');
|
||||
app.main(
|
||||
@@ -155,7 +166,36 @@ void main() {
|
||||
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');
|
||||
|
||||
// ── Add account ────────────────────────────────────────────────────────
|
||||
@@ -248,6 +288,12 @@ void main() {
|
||||
find.widgetWithText(TextFormField, 'To'),
|
||||
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(
|
||||
find.widgetWithText(TextFormField, 'Subject'),
|
||||
subject,
|
||||
@@ -257,6 +303,10 @@ void main() {
|
||||
await tester.ensureVisible(bodyField);
|
||||
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');
|
||||
await tester.tap(find.byIcon(Icons.send));
|
||||
// Wait for ComposeScreen to pop back to EmailListScreen after send.
|
||||
|
||||
@@ -7,6 +7,7 @@ class SavedDraft {
|
||||
final String subjectText;
|
||||
final String bodyText;
|
||||
final DateTime updatedAt;
|
||||
final String? imapServerId;
|
||||
|
||||
const SavedDraft({
|
||||
required this.id,
|
||||
@@ -17,5 +18,6 @@ class SavedDraft {
|
||||
required this.subjectText,
|
||||
required this.bodyText,
|
||||
required this.updatedAt,
|
||||
this.imapServerId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ class Email {
|
||||
final String? references;
|
||||
final DateTime? snoozedUntil;
|
||||
final String? snoozedFromMailboxPath;
|
||||
// RFC 2369 List-Unsubscribe header value, e.g. "<mailto:...>, <https://...>".
|
||||
final String? listUnsubscribeHeader;
|
||||
|
||||
const Email({
|
||||
required this.id,
|
||||
@@ -43,6 +45,7 @@ class Email {
|
||||
this.references,
|
||||
this.snoozedUntil,
|
||||
this.snoozedFromMailboxPath,
|
||||
this.listUnsubscribeHeader,
|
||||
});
|
||||
|
||||
factory Email.fromJson(Map<String, dynamic> json) {
|
||||
@@ -77,6 +80,7 @@ class Email {
|
||||
? DateTime.parse(json['snoozedUntil'] as String)
|
||||
: null,
|
||||
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
||||
listUnsubscribeHeader: json['listUnsubscribeHeader'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +106,7 @@ class Email {
|
||||
'references': references,
|
||||
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
||||
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
||||
'listUnsubscribeHeader': listUnsubscribeHeader,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +131,7 @@ class Email {
|
||||
String? references,
|
||||
DateTime? snoozedUntil,
|
||||
String? snoozedFromMailboxPath,
|
||||
String? listUnsubscribeHeader,
|
||||
}) {
|
||||
return Email(
|
||||
id: id ?? this.id,
|
||||
@@ -149,6 +155,8 @@ class Email {
|
||||
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
||||
snoozedFromMailboxPath:
|
||||
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
||||
listUnsubscribeHeader:
|
||||
listUnsubscribeHeader ?? this.listUnsubscribeHeader,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -224,12 +232,29 @@ class EmailHeader {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final String emailId;
|
||||
final String? textBody;
|
||||
final String? htmlBody;
|
||||
final List<EmailAttachment> attachments;
|
||||
final List<EmailHeader> headers;
|
||||
final MimePart? mimeTree;
|
||||
|
||||
const EmailBody({
|
||||
required this.emailId,
|
||||
@@ -237,6 +262,7 @@ class EmailBody {
|
||||
this.htmlBody,
|
||||
required this.attachments,
|
||||
this.headers = const [],
|
||||
this.mimeTree,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,10 @@ abstract class DraftRepository {
|
||||
|
||||
/// Permanently removes the draft with [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);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
abstract class EmailRepository {
|
||||
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
|
||||
Stream<List<Email>> observeEmails(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
});
|
||||
|
||||
/// Groups emails by threadId and returns one [EmailThread] per thread,
|
||||
/// sorted by the latest message date descending.
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
);
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
});
|
||||
|
||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
@@ -22,6 +27,7 @@ abstract class EmailRepository {
|
||||
Future<EmailBody> getEmailBody(String emailId);
|
||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath);
|
||||
Future<void> moveEmail(String emailId, String destMailboxPath);
|
||||
|
||||
/// Deletes the email. Returns the path of the mailbox it was moved to
|
||||
@@ -35,6 +41,10 @@ abstract class EmailRepository {
|
||||
/// return the cached path without a network round-trip.
|
||||
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].
|
||||
/// Results come from the server (IMAP SEARCH) and are not cached.
|
||||
Future<List<Email>> searchEmails(
|
||||
@@ -51,6 +61,14 @@ abstract class EmailRepository {
|
||||
/// accounts if null) whose from, to, or cc fields contain [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.
|
||||
/// Returns the number of changes successfully applied.
|
||||
Future<int> flushPendingChanges(String accountId, String password);
|
||||
@@ -81,6 +99,17 @@ abstract class EmailRepository {
|
||||
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
||||
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.
|
||||
/// Used by AccountSyncManager to trigger an immediate flush.
|
||||
Stream<String> get onChangesQueued;
|
||||
@@ -99,4 +128,9 @@ abstract class EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
);
|
||||
|
||||
/// Deletes all locally-cached email rows and pending changes for [accountId],
|
||||
/// while preserving EmailBodies so already-downloaded content is not lost.
|
||||
/// Also resets sync-state checkpoints so the next sync fetches everything fresh.
|
||||
Future<void> clearForResync(String accountId);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ abstract class MailboxRepository {
|
||||
|
||||
/// Returns the first mailbox with the given [role] for [accountId], or null.
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role);
|
||||
|
||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||
Future<void> clearForResync(String accountId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract interface class SearchHistoryRepository {
|
||||
Future<List<String>> getRecentSearches();
|
||||
Future<void> saveSearch(String query);
|
||||
Future<void> clearHistory();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
|
||||
/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing.
|
||||
abstract class ShareKeyRepository {
|
||||
/// Generates a new key pair and persists it with a 20-minute expiry.
|
||||
Future<ShareKeyMaterial> createKeyPair();
|
||||
|
||||
/// Returns the key pair whose ID matches [keyId], or null if not found /
|
||||
/// expired.
|
||||
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ class MailboxSyncStats {
|
||||
required this.fetched,
|
||||
required this.skipped,
|
||||
required this.bytesTransferred,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
final String mailboxPath;
|
||||
final int fetched;
|
||||
final int skipped;
|
||||
final int bytesTransferred;
|
||||
final Duration? duration;
|
||||
}
|
||||
|
||||
class SyncLogEntry {
|
||||
@@ -65,6 +67,10 @@ abstract class SyncLogRepository {
|
||||
});
|
||||
|
||||
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId);
|
||||
|
||||
/// Emits the error message of the most recent sync attempt for [accountId],
|
||||
/// or null when the last sync succeeded (or no syncs have run yet).
|
||||
Stream<String?> observeLastError(String accountId);
|
||||
}
|
||||
|
||||
class NoOpSyncLogRepository implements SyncLogRepository {
|
||||
@@ -90,4 +96,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
||||
@override
|
||||
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<String?> observeLastError(String accountId) => Stream.value(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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(
|
||||
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(
|
||||
accountEmail.hashCode & 0x7FFFFFFF,
|
||||
'New mail',
|
||||
accountEmail,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_kChannelId,
|
||||
_kChannelName,
|
||||
channelDescription: 'Notifications for new incoming mail',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,10 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
final newList = [...state, action];
|
||||
if (newList.length > _maxHistory) {
|
||||
final removed = newList.removeAt(0);
|
||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(removed.id));
|
||||
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
||||
}
|
||||
state = newList;
|
||||
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
|
||||
await _ref.read(undoRepositoryProvider).saveAction(action);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
@@ -45,17 +45,17 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
final UndoAction action;
|
||||
if (actionId == null) {
|
||||
action = state.last;
|
||||
state = state.sublist(0, state.length - 1);
|
||||
} else {
|
||||
try {
|
||||
action = state.firstWhere((a) => a.id == actionId);
|
||||
state = state.where((a) => a.id != actionId).toList();
|
||||
} catch (e) {
|
||||
return; // Action not found
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(_ref.read(undoRepositoryProvider).deleteAction(action.id));
|
||||
// Keep the original entry in state and DB so the user can see what
|
||||
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
|
||||
// the local change). The inverse action added below allows undoing the undo.
|
||||
|
||||
final repo = _ref.read(emailRepositoryProvider);
|
||||
|
||||
@@ -70,10 +70,22 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
? null
|
||||
: action.originalEmails.where((e) => e.id == id).firstOrNull;
|
||||
|
||||
// 2. If row is missing (hard delete), restore it first.
|
||||
// We restore it at its CURRENT state (where it is on the server,
|
||||
// or where it was moving to).
|
||||
if (original != null) {
|
||||
// 2. Resolve the current DB row for the email.
|
||||
// For IMAP, after a server-applied move the email gets a new UID, so
|
||||
// the original id ('accountId:oldUid') no longer exists. Look it up by
|
||||
// Message-ID so we use the correct UID in the pending change.
|
||||
var currentEmail = await repo.getEmail(id);
|
||||
if (currentEmail == null && original?.messageId != null) {
|
||||
currentEmail = await repo.findEmailByMessageId(
|
||||
action.accountId,
|
||||
original!.messageId!,
|
||||
);
|
||||
}
|
||||
final currentId = currentEmail?.id ?? id;
|
||||
|
||||
// 3. If the row is absent (hard delete or UID changed after sync),
|
||||
// restore it from the saved snapshot so moveEmail can find it.
|
||||
if (currentEmail == null && original != null) {
|
||||
final currentPath = cancelled
|
||||
? action.sourceMailboxPath
|
||||
: (action.destinationMailboxPath ?? action.sourceMailboxPath);
|
||||
@@ -82,19 +94,40 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Move it back to source.
|
||||
// 4. Move it back to source.
|
||||
// This updates local DB optimistically and (if not cancelled) enqueues
|
||||
// a reverse move on the server.
|
||||
await repo.moveEmail(id, action.sourceMailboxPath);
|
||||
// a reverse move on the server using the correct UID.
|
||||
await repo.moveEmail(currentId, action.sourceMailboxPath);
|
||||
|
||||
if (cancelled) {
|
||||
// 4. If we successfully cancelled the original, the reverse move
|
||||
// 5. If we successfully cancelled the original, the reverse move
|
||||
// we just enqueued is redundant.
|
||||
await repo.cancelPendingChange(id, 'move');
|
||||
await repo.cancelPendingChange(currentId, 'move');
|
||||
}
|
||||
} catch (e) {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
// Add a reverse action so the undo log always retains a record and the
|
||||
// user can re-apply the original operation. sourceMailboxPath on the
|
||||
// inverse is the original destination (e.g. Trash) so that undoing the
|
||||
// inverse moves emails back there; destinationMailboxPath records where
|
||||
// they are now (the original source, e.g. INBOX).
|
||||
final inverseDest = action.destinationMailboxPath;
|
||||
if (inverseDest != null) {
|
||||
await pushAction(
|
||||
UndoAction(
|
||||
id: '${action.id}-inv',
|
||||
accountId: action.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: action.emailIds,
|
||||
sourceMailboxPath: inverseDest,
|
||||
destinationMailboxPath: action.sourceMailboxPath,
|
||||
originalEmails: action.originalEmails,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const _kAppVersion = String.fromEnvironment('GIT_HASH');
|
||||
const _kLatestJsonUrl = 'https://sharedinbox.de/latest.json';
|
||||
|
||||
class UpdateInfo {
|
||||
const UpdateInfo({required this.latestVersion, required this.downloadUrl});
|
||||
|
||||
final String latestVersion;
|
||||
final String downloadUrl;
|
||||
}
|
||||
|
||||
/// Returns an [UpdateInfo] when a newer Linux or Windows version is available,
|
||||
/// or null if the app is up to date, the version is unknown, or the platform
|
||||
/// is not a supported desktop.
|
||||
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||
final platformKey = Platform.isLinux
|
||||
? 'linux'
|
||||
: Platform.isWindows
|
||||
? 'windows'
|
||||
: null;
|
||||
if (platformKey == null || _kAppVersion.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse(_kLatestJsonUrl))
|
||||
.timeout(const Duration(seconds: 10));
|
||||
if (resp.statusCode != 200) return null;
|
||||
final json = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final latest = json['version'] as String?;
|
||||
final url = json[platformKey] as String?;
|
||||
if (latest == null || url == null) return null;
|
||||
if (latest == _kAppVersion) return null;
|
||||
return UpdateInfo(latestVersion: latest, downloadUrl: url);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
sealed class SieveAction {}
|
||||
|
||||
final class FileIntoAction extends SieveAction {
|
||||
FileIntoAction(this.folder);
|
||||
final String folder;
|
||||
}
|
||||
|
||||
final class KeepAction extends SieveAction {}
|
||||
|
||||
final class DiscardAction extends SieveAction {}
|
||||
|
||||
final class MarkAsSeenAction extends SieveAction {}
|
||||
|
||||
final class FlagAction extends SieveAction {
|
||||
FlagAction(this.flags);
|
||||
final List<String> flags;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
sealed class SieveCondition {}
|
||||
|
||||
final class HeaderCondition extends SieveCondition {
|
||||
HeaderCondition(this.headers, this.matchType, this.keyList);
|
||||
final List<String> headers;
|
||||
final String matchType; // ':contains', ':is', ':matches'
|
||||
final List<String> keyList;
|
||||
}
|
||||
|
||||
final class SizeCondition extends SieveCondition {
|
||||
SizeCondition(this.comparison, this.bytes);
|
||||
final String comparison; // ':over' or ':under'
|
||||
final int bytes;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||
|
||||
/// Parses a Sieve script (RFC 5228 subset) into a flat list of [SieveRule]s.
|
||||
///
|
||||
/// Supported commands: require, if, elsif, else, fileinto, keep, discard,
|
||||
/// flag, setflag, addflag, stop.
|
||||
/// Supported tests: header, address, size, exists, allof, anyof, not, true.
|
||||
/// Supported match types: :contains, :is, :matches.
|
||||
class SieveParser {
|
||||
List<SieveRule> parse(String script) {
|
||||
final scanner = _Scanner(script);
|
||||
final rules = <SieveRule>[];
|
||||
_parseStatements(scanner, rules);
|
||||
return rules;
|
||||
}
|
||||
|
||||
void _parseStatements(_Scanner s, List<SieveRule> out) {
|
||||
while (!s.isAtEnd) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.isAtEnd) break;
|
||||
|
||||
final word = s.peekWord();
|
||||
if (word == null) break;
|
||||
|
||||
if (word == 'require') {
|
||||
_parseRequire(s);
|
||||
} else if (word == 'if') {
|
||||
_parseIf(s, out);
|
||||
} else if (word == 'elsif' || word == 'else') {
|
||||
// Reached by _parseIf, should not appear at top level.
|
||||
break;
|
||||
} else if (word == '}') {
|
||||
break;
|
||||
} else {
|
||||
final action = _tryParseAction(s);
|
||||
if (action != null) {
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: 'single',
|
||||
conditions: const [],
|
||||
actions: [action],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
s.skipToNextSemicolon();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _parseRequire(_Scanner s) {
|
||||
s.expectWord('require');
|
||||
s.skipWhitespaceAndComments();
|
||||
_parseStringOrList(s); // discard capability list
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
}
|
||||
|
||||
// Monotonically increasing id shared per parse run, threaded via closure.
|
||||
int _groupCounter = 0;
|
||||
|
||||
void _parseIf(_Scanner s, List<SieveRule> out) {
|
||||
final groupId = ++_groupCounter;
|
||||
|
||||
s.expectWord('if');
|
||||
s.skipWhitespaceAndComments();
|
||||
final (joinType, conditions) = _parseTest(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
final ifActions = _parseBlock(s);
|
||||
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: joinType,
|
||||
conditions: conditions,
|
||||
actions: ifActions,
|
||||
branchGroupId: groupId,
|
||||
),
|
||||
);
|
||||
|
||||
// Parse zero or more elsif branches.
|
||||
while (true) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peekWord() != 'elsif') break;
|
||||
s.expectWord('elsif');
|
||||
s.skipWhitespaceAndComments();
|
||||
final (ej, ec) = _parseTest(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
final elsifActions = _parseBlock(s);
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: ej,
|
||||
conditions: ec,
|
||||
actions: elsifActions,
|
||||
branchGroupId: groupId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Optional else branch.
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peekWord() == 'else') {
|
||||
s.expectWord('else');
|
||||
s.skipWhitespaceAndComments();
|
||||
final elseActions = _parseBlock(s);
|
||||
out.add(
|
||||
SieveRule(
|
||||
joinType: 'single',
|
||||
conditions: const [],
|
||||
actions: elseActions,
|
||||
branchGroupId: groupId,
|
||||
isElseBranch: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<SieveAction> _parseBlock(_Scanner s) {
|
||||
s.expectChar('{');
|
||||
final blockRules = <SieveRule>[];
|
||||
_parseStatements(s, blockRules);
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar('}');
|
||||
return blockRules.expand((r) => r.actions).toList();
|
||||
}
|
||||
|
||||
/// Returns (joinType, conditions).
|
||||
(String, List<SieveCondition>) _parseTest(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final word = s.peekWord();
|
||||
|
||||
if (word == 'allof' || word == 'anyof') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar('(');
|
||||
final conditions = <SieveCondition>[];
|
||||
while (true) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ')') break;
|
||||
final (_, conds) = _parseTest(s);
|
||||
conditions.addAll(conds);
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ',') {
|
||||
s.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(')');
|
||||
return (word!, conditions);
|
||||
}
|
||||
|
||||
final cond = _parseSingleTest(s);
|
||||
return ('single', cond != null ? [cond] : []);
|
||||
}
|
||||
|
||||
SieveCondition? _parseSingleTest(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
|
||||
if (word == 'not') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
// Negation is not represented in the flat rule model; the caller
|
||||
// should handle the negated condition separately. For now we parse
|
||||
// and return the inner condition unchanged (best-effort for this subset).
|
||||
return _parseSingleTest(s);
|
||||
}
|
||||
|
||||
if (word == 'true') {
|
||||
s.readWord();
|
||||
return null; // no condition = always matches
|
||||
}
|
||||
|
||||
if (word == 'header' || word == 'address') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final matchType = _parseMatchType(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
// Consume optional :comparator "..." tagged argument.
|
||||
if (s.peekTaggedArg() == ':comparator') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
_parseStringOrList(s); // discard comparator value
|
||||
s.skipWhitespaceAndComments();
|
||||
}
|
||||
final headers = _parseStringOrList(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
final keys = _parseStringOrList(s);
|
||||
return HeaderCondition(headers, matchType, keys);
|
||||
}
|
||||
|
||||
if (word == 'exists') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final headers = _parseStringOrList(s);
|
||||
// Represent exists as :contains "" so any non-empty value matches.
|
||||
return HeaderCondition(headers, ':contains', const ['']);
|
||||
}
|
||||
|
||||
if (word == 'size') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final comp = s.readTaggedArg(); // :over or :under
|
||||
s.skipWhitespaceAndComments();
|
||||
final bytes = _parseSizeNumber(s);
|
||||
return SizeCondition(comp, bytes);
|
||||
}
|
||||
|
||||
// Unknown test — skip to closing paren or brace.
|
||||
s.readWord();
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parseMatchType(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final tag = s.peekTaggedArg();
|
||||
if (tag == ':contains' || tag == ':is' || tag == ':matches') {
|
||||
s.readWord();
|
||||
return tag!;
|
||||
}
|
||||
// Default per RFC 5228 is :is.
|
||||
return ':is';
|
||||
}
|
||||
|
||||
List<String> _parseStringOrList(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == '[') {
|
||||
s.advance(); // consume '['
|
||||
final items = <String>[];
|
||||
while (true) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ']') {
|
||||
s.advance();
|
||||
break;
|
||||
}
|
||||
items.add(_parseString(s));
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == ',') {
|
||||
s.advance();
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
return [_parseString(s)];
|
||||
}
|
||||
|
||||
String _parseString(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == '"') {
|
||||
return s.readQuotedString();
|
||||
}
|
||||
// Multi-line text: text:...\r\n.\r\n (RFC 5228 §2.4.2)
|
||||
if (s.peekWord()?.toLowerCase() == 'text:') {
|
||||
return s.readTextBlock();
|
||||
}
|
||||
throw SieveParseException(
|
||||
'Expected string at position ${s.position}: "${s.remaining.substring(0, 20)}"',
|
||||
);
|
||||
}
|
||||
|
||||
int _parseSizeNumber(_Scanner s) {
|
||||
final digits = s.readDigits();
|
||||
final value = int.parse(digits);
|
||||
final unit = s.peekSizeUnit();
|
||||
if (unit != null) {
|
||||
s.advance();
|
||||
return switch (unit.toUpperCase()) {
|
||||
'K' => value * 1024,
|
||||
'M' => value * 1024 * 1024,
|
||||
'G' => value * 1024 * 1024 * 1024,
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
SieveAction? _tryParseAction(_Scanner s) {
|
||||
s.skipWhitespaceAndComments();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
|
||||
if (word == 'fileinto') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
final folder = _parseString(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return FileIntoAction(folder);
|
||||
}
|
||||
if (word == 'keep') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return KeepAction();
|
||||
}
|
||||
if (word == 'discard') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return DiscardAction();
|
||||
}
|
||||
if (word == 'stop') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return KeepAction(); // stop with no prior action = implicit keep
|
||||
}
|
||||
if (word == 'flag' || word == 'setflag' || word == 'addflag') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
// Optional variable name (string arg before the flag list).
|
||||
final peek = s.peek();
|
||||
List<String> flags;
|
||||
if (peek == '"') {
|
||||
final first = _parseString(s);
|
||||
s.skipWhitespaceAndComments();
|
||||
if (s.peek() == '[' || s.peek() == '"') {
|
||||
// first was the variable name, next is the flag list
|
||||
flags = _parseStringOrList(s);
|
||||
} else {
|
||||
flags = [first];
|
||||
}
|
||||
} else {
|
||||
flags = _parseStringOrList(s);
|
||||
}
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
if (flags.any(
|
||||
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||
)) {
|
||||
return MarkAsSeenAction();
|
||||
}
|
||||
return FlagAction(flags);
|
||||
}
|
||||
if (word == 'mark') {
|
||||
s.readWord();
|
||||
s.skipWhitespaceAndComments();
|
||||
s.expectChar(';');
|
||||
return MarkAsSeenAction();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SieveParseException implements Exception {
|
||||
SieveParseException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'SieveParseException: $message';
|
||||
}
|
||||
|
||||
class _Scanner {
|
||||
_Scanner(this._src);
|
||||
|
||||
final String _src;
|
||||
int _pos = 0;
|
||||
|
||||
int get position => _pos;
|
||||
bool get isAtEnd => _pos >= _src.length;
|
||||
String get remaining => _pos < _src.length ? _src.substring(_pos) : '';
|
||||
|
||||
String? peek() {
|
||||
if (isAtEnd) return null;
|
||||
return _src[_pos];
|
||||
}
|
||||
|
||||
String advance() {
|
||||
if (isAtEnd) throw SieveParseException('Unexpected end of input');
|
||||
return _src[_pos++];
|
||||
}
|
||||
|
||||
void skipWhitespaceAndComments() {
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||
_pos++;
|
||||
} else if (ch == '#') {
|
||||
// Line comment — skip to end of line.
|
||||
while (!isAtEnd && _src[_pos] != '\n') {
|
||||
_pos++;
|
||||
}
|
||||
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||
// Block comment.
|
||||
_pos += 2;
|
||||
while (_pos + 1 < _src.length) {
|
||||
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||
_pos += 2;
|
||||
break;
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Peeks at the next word-like token (letters/digits/underscores/colons for
|
||||
/// tagged args, and special single-char tokens like `{`, `}`, `;`).
|
||||
String? peekWord() {
|
||||
if (isAtEnd) return null;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) return ch;
|
||||
if (ch == ':') {
|
||||
// Tagged arg like :contains
|
||||
final start = _pos;
|
||||
var end = _pos + 1;
|
||||
while (end < _src.length && _isWordChar(_src[end])) {
|
||||
end++;
|
||||
}
|
||||
return _src.substring(start, end).toLowerCase();
|
||||
}
|
||||
if (_isWordChar(ch)) {
|
||||
final start = _pos;
|
||||
var end = _pos + 1;
|
||||
while (
|
||||
end < _src.length && (_isWordChar(_src[end]) || _src[end] == ':')) {
|
||||
// Include trailing colon for "text:" multiline token.
|
||||
if (_src[end] == ':') {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
return _src.substring(start, end).toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String readWord() {
|
||||
final start = _pos;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) {
|
||||
_pos++;
|
||||
return ch;
|
||||
}
|
||||
if (ch == ':') {
|
||||
_pos++;
|
||||
while (!isAtEnd && _isWordChar(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
while (!isAtEnd && (_isWordChar(_src[_pos]) || _src[_pos] == ':')) {
|
||||
if (_src[_pos] == ':') {
|
||||
_pos++;
|
||||
break;
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
return _src.substring(start, _pos).toLowerCase();
|
||||
}
|
||||
|
||||
String? peekTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return peekWord();
|
||||
return null;
|
||||
}
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw SieveParseException(
|
||||
'Expected tagged argument at position $_pos',
|
||||
);
|
||||
}
|
||||
|
||||
String? peekSizeUnit() {
|
||||
if (isAtEnd) return null;
|
||||
final ch = _src[_pos].toUpperCase();
|
||||
if (ch == 'K' || ch == 'M' || ch == 'G') return ch;
|
||||
return null;
|
||||
}
|
||||
|
||||
String readDigits() {
|
||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||
throw SieveParseException(
|
||||
'Expected number at position $_pos',
|
||||
);
|
||||
}
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
return _src.substring(start, _pos);
|
||||
}
|
||||
|
||||
String readQuotedString() {
|
||||
if (_src[_pos] != '"') {
|
||||
throw SieveParseException(
|
||||
'Expected " at position $_pos',
|
||||
);
|
||||
}
|
||||
_pos++; // skip opening quote
|
||||
final buf = StringBuffer();
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == '"') {
|
||||
_pos++;
|
||||
return buf.toString();
|
||||
}
|
||||
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||
_pos++;
|
||||
buf.write(_src[_pos]);
|
||||
_pos++;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
throw SieveParseException('Unterminated string');
|
||||
}
|
||||
|
||||
/// Parses a `text:` multi-line block (RFC 5228 §2.4.2).
|
||||
/// Format: `text:\r\n<lines>\r\n.\r\n`
|
||||
String readTextBlock() {
|
||||
// Consume "text:"
|
||||
while (!isAtEnd && _src[_pos] != ':') {
|
||||
_pos++;
|
||||
}
|
||||
if (!isAtEnd) _pos++; // skip ':'
|
||||
// Skip optional whitespace then newline.
|
||||
while (!isAtEnd && (_src[_pos] == ' ' || _src[_pos] == '\t')) {
|
||||
_pos++;
|
||||
}
|
||||
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||
final buf = StringBuffer();
|
||||
while (!isAtEnd) {
|
||||
// Check for terminator: a lone "." on its own line.
|
||||
if (_src[_pos] == '.' &&
|
||||
(_pos + 1 >= _src.length ||
|
||||
_src[_pos + 1] == '\r' ||
|
||||
_src[_pos + 1] == '\n')) {
|
||||
_pos++;
|
||||
if (!isAtEnd && _src[_pos] == '\r') _pos++;
|
||||
if (!isAtEnd && _src[_pos] == '\n') _pos++;
|
||||
break;
|
||||
}
|
||||
buf.write(_src[_pos]);
|
||||
_pos++;
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
void expectChar(String ch) {
|
||||
skipWhitespaceAndComments();
|
||||
if (isAtEnd || _src[_pos] != ch) {
|
||||
throw SieveParseException(
|
||||
'Expected "$ch" at position $_pos, got '
|
||||
'"${isAtEnd ? "EOF" : _src[_pos]}"',
|
||||
);
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
|
||||
void expectWord(String word) {
|
||||
skipWhitespaceAndComments();
|
||||
final got = readWord();
|
||||
if (got.toLowerCase() != word.toLowerCase()) {
|
||||
throw SieveParseException(
|
||||
'Expected "$word" at position $_pos, got "$got"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void skipToNextSemicolon() {
|
||||
while (!isAtEnd && _src[_pos] != ';') {
|
||||
_pos++;
|
||||
}
|
||||
if (!isAtEnd) _pos++; // skip ';'
|
||||
}
|
||||
|
||||
static bool _isWordChar(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return (c >= 0x41 && c <= 0x5A) || // A-Z
|
||||
(c >= 0x61 && c <= 0x7A) || // a-z
|
||||
(c >= 0x30 && c <= 0x39) || // 0-9
|
||||
c == 0x5F || // _
|
||||
c == 0x2D; // -
|
||||
}
|
||||
|
||||
static bool _isDigit(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return c >= 0x30 && c <= 0x39;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
|
||||
class SieveRule {
|
||||
const SieveRule({
|
||||
required this.joinType,
|
||||
required this.conditions,
|
||||
required this.actions,
|
||||
this.branchGroupId,
|
||||
this.isElseBranch = false,
|
||||
});
|
||||
|
||||
// 'allof', 'anyof', or 'single'
|
||||
final String joinType;
|
||||
final List<SieveCondition> conditions;
|
||||
final List<SieveAction> actions;
|
||||
// Non-null groups this rule into an if/elsif/else chain.
|
||||
final int? branchGroupId;
|
||||
// True for the unconditional else branch.
|
||||
final bool isElseBranch;
|
||||
}
|
||||
@@ -4,12 +4,16 @@ import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult;
|
||||
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/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
||||
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.
|
||||
///
|
||||
@@ -22,19 +26,35 @@ class AccountSyncManager {
|
||||
this._emails, {
|
||||
ImapConnectFn imapConnect = connectImap,
|
||||
SyncLogRepository syncLog = const NoOpSyncLogRepository(),
|
||||
DraftRepository? drafts,
|
||||
OnNewMailCallback? onNewMail,
|
||||
}) : _imapConnect = imapConnect,
|
||||
_syncLog = syncLog;
|
||||
_syncLog = syncLog,
|
||||
_drafts = drafts,
|
||||
_onNewMail = onNewMail;
|
||||
|
||||
final AccountRepository _accounts;
|
||||
final MailboxRepository _mailboxes;
|
||||
final EmailRepository _emails;
|
||||
final ImapConnectFn _imapConnect;
|
||||
final SyncLogRepository _syncLog;
|
||||
final DraftRepository? _drafts;
|
||||
final OnNewMailCallback? _onNewMail;
|
||||
|
||||
final Map<String, _SyncLoop> _active = {};
|
||||
StreamSubscription<List<Account>>? _accountsSub;
|
||||
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() {
|
||||
_onChangesSub = _emails.onChangesQueued.listen((accountId) {
|
||||
_active[accountId]?.kick();
|
||||
@@ -45,6 +65,7 @@ class AccountSyncManager {
|
||||
|
||||
for (final account in accounts) {
|
||||
if (_active.containsKey(account.id)) continue;
|
||||
final id = account.id;
|
||||
final loop = switch (account.type) {
|
||||
AccountType.imap => _AccountSync(
|
||||
account,
|
||||
@@ -53,6 +74,10 @@ class AccountSyncManager {
|
||||
_emails,
|
||||
_imapConnect,
|
||||
_syncLog,
|
||||
_drafts,
|
||||
_onNewMail,
|
||||
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||
),
|
||||
AccountType.jmap => _JmapAccountSync(
|
||||
account,
|
||||
@@ -60,6 +85,8 @@ class AccountSyncManager {
|
||||
_emails,
|
||||
_accounts,
|
||||
_syncLog,
|
||||
onSyncStart: () => _emitSyncing(id, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(id, syncing: false),
|
||||
),
|
||||
};
|
||||
_active[account.id] = loop;
|
||||
@@ -81,6 +108,7 @@ class AccountSyncManager {
|
||||
s.stop();
|
||||
}
|
||||
_active.clear();
|
||||
unawaited(_syncPhaseCtrl.close());
|
||||
}
|
||||
|
||||
/// Wakes the idle/wait phase of the given account's sync loop so a new
|
||||
@@ -88,6 +116,49 @@ class AccountSyncManager {
|
||||
void syncNow(String accountId) {
|
||||
_active[accountId]?.kick();
|
||||
}
|
||||
|
||||
/// Clears all locally-cached emails and mailboxes for [accountId], then
|
||||
/// immediately starts a fresh sync cycle. Use this as an escape hatch when
|
||||
/// the local DB is believed to be out of sync with the server.
|
||||
Future<void> forceResync(String accountId) async {
|
||||
_active.remove(accountId)?.stop();
|
||||
|
||||
await _emails.clearForResync(accountId);
|
||||
await _mailboxes.clearForResync(accountId);
|
||||
|
||||
final accounts = await _accounts.observeAccounts().first;
|
||||
final account = accounts.cast<Account?>().firstWhere(
|
||||
(a) => a?.id == accountId,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (account == null) return;
|
||||
|
||||
final loop = switch (account.type) {
|
||||
AccountType.imap => _AccountSync(
|
||||
account,
|
||||
_accounts,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_imapConnect,
|
||||
_syncLog,
|
||||
_drafts,
|
||||
_onNewMail,
|
||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||
),
|
||||
AccountType.jmap => _JmapAccountSync(
|
||||
account,
|
||||
_mailboxes,
|
||||
_emails,
|
||||
_accounts,
|
||||
_syncLog,
|
||||
onSyncStart: () => _emitSyncing(accountId, syncing: true),
|
||||
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
|
||||
),
|
||||
};
|
||||
_active[accountId] = loop;
|
||||
loop.start();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared interface ──────────────────────────────────────────────────────────
|
||||
@@ -108,7 +179,12 @@ class _AccountSync implements _SyncLoop {
|
||||
this._emails,
|
||||
this._imapConnect,
|
||||
this._syncLog,
|
||||
);
|
||||
this._drafts,
|
||||
this._onNewMail, {
|
||||
void Function()? onSyncStart,
|
||||
void Function()? onSyncEnd,
|
||||
}) : _onSyncStart = onSyncStart,
|
||||
_onSyncEnd = onSyncEnd;
|
||||
|
||||
final Account account;
|
||||
final AccountRepository _accounts;
|
||||
@@ -116,11 +192,16 @@ class _AccountSync implements _SyncLoop {
|
||||
final EmailRepository _emails;
|
||||
final ImapConnectFn _imapConnect;
|
||||
final SyncLogRepository _syncLog;
|
||||
final DraftRepository? _drafts;
|
||||
final OnNewMailCallback? _onNewMail;
|
||||
final void Function()? _onSyncStart;
|
||||
final void Function()? _onSyncEnd;
|
||||
|
||||
imap.ImapClient? _idleClient;
|
||||
bool _running = false;
|
||||
int _backoffSeconds = 5;
|
||||
Completer<void>? _stopSignal;
|
||||
Timer? _waitTimer;
|
||||
|
||||
@override
|
||||
void start() {
|
||||
@@ -148,6 +229,7 @@ class _AccountSync implements _SyncLoop {
|
||||
Future<void> _loop() async {
|
||||
while (_running) {
|
||||
final startedAt = DateTime.now();
|
||||
_onSyncStart?.call();
|
||||
try {
|
||||
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
||||
account.verbose,
|
||||
@@ -167,8 +249,10 @@ class _AccountSync implements _SyncLoop {
|
||||
protocolLog: capturedLog,
|
||||
);
|
||||
_backoffSeconds = 5;
|
||||
_onSyncEnd?.call();
|
||||
await _idle();
|
||||
} catch (e, st) {
|
||||
_onSyncEnd?.call();
|
||||
final isPermanent = _isPermanentError(e);
|
||||
try {
|
||||
await _syncLog.log(
|
||||
@@ -209,6 +293,7 @@ class _AccountSync implements _SyncLoop {
|
||||
}
|
||||
|
||||
bool _isPermanentError(Object e) {
|
||||
if (isTlsConfigError(e)) return true;
|
||||
final s = e.toString().toLowerCase();
|
||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||
return s.contains('invalid credentials') ||
|
||||
@@ -219,11 +304,16 @@ class _AccountSync implements _SyncLoop {
|
||||
Future<void> _waitSeconds(int seconds) async {
|
||||
if (!_running) return;
|
||||
_stopSignal = Completer<void>();
|
||||
await Future.any([
|
||||
Future.delayed(Duration(seconds: seconds)),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
_stopSignal = null;
|
||||
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||
});
|
||||
try {
|
||||
await _stopSignal!.future;
|
||||
} finally {
|
||||
_waitTimer?.cancel();
|
||||
_waitTimer = null;
|
||||
_stopSignal = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||
@@ -242,6 +332,8 @@ class _AccountSync implements _SyncLoop {
|
||||
Future<_SyncStats> _sync() async {
|
||||
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.
|
||||
await _emails.wakeUpEmails(account.id);
|
||||
|
||||
@@ -255,6 +347,7 @@ class _AccountSync implements _SyncLoop {
|
||||
final mailboxStats = <MailboxSyncStats>[];
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!_running) break;
|
||||
final mailboxStart = DateTime.now();
|
||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||
emailResult += r;
|
||||
mailboxStats.add(
|
||||
@@ -263,9 +356,11 @@ class _AccountSync implements _SyncLoop {
|
||||
fetched: r.fetched,
|
||||
skipped: r.skipped,
|
||||
bytesTransferred: r.bytesTransferred,
|
||||
duration: DateTime.now().difference(mailboxStart),
|
||||
),
|
||||
);
|
||||
}
|
||||
await _emails.applySieveRules(account.id);
|
||||
return _SyncStats(
|
||||
emailsFetched: emailResult.fetched,
|
||||
emailsSkipped: emailResult.skipped,
|
||||
@@ -288,6 +383,7 @@ class _AccountSync implements _SyncLoop {
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
|
||||
final newMessageCompleter = Completer<void>();
|
||||
var hasNewMail = false;
|
||||
|
||||
final sub = client.eventBus
|
||||
.on<imap.ImapEvent>()
|
||||
@@ -295,7 +391,11 @@ class _AccountSync implements _SyncLoop {
|
||||
(e) =>
|
||||
e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent,
|
||||
)
|
||||
.listen((_) {
|
||||
.listen((e) {
|
||||
if (e is imap.ImapMessagesExistEvent &&
|
||||
e.newMessagesExists > e.oldMessagesExists) {
|
||||
hasNewMail = true;
|
||||
}
|
||||
if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
|
||||
});
|
||||
|
||||
@@ -303,14 +403,23 @@ class _AccountSync implements _SyncLoop {
|
||||
|
||||
// Cap IDLE at 25 minutes (RFC 2177). Also wakes up when stop() is
|
||||
// called or a new message / expunge event arrives.
|
||||
await Future.any([
|
||||
newMessageCompleter.future,
|
||||
Future.delayed(const Duration(minutes: 25)),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
final idleTimer = Timer(const Duration(minutes: 25), () {
|
||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||
_stopSignal!.complete();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Future.any([newMessageCompleter.future, _stopSignal!.future]);
|
||||
} finally {
|
||||
idleTimer.cancel();
|
||||
}
|
||||
|
||||
await client.idleDone();
|
||||
await sub.cancel();
|
||||
|
||||
if (hasNewMail) {
|
||||
unawaited(_onNewMail?.call(account.email));
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
_idleClient = null;
|
||||
@@ -327,18 +436,24 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
this._mailboxes,
|
||||
this._emails,
|
||||
this._accounts,
|
||||
this._syncLog,
|
||||
);
|
||||
this._syncLog, {
|
||||
void Function()? onSyncStart,
|
||||
void Function()? onSyncEnd,
|
||||
}) : _onSyncStart = onSyncStart,
|
||||
_onSyncEnd = onSyncEnd;
|
||||
|
||||
final Account account;
|
||||
final MailboxRepository _mailboxes;
|
||||
final EmailRepository _emails;
|
||||
final AccountRepository _accounts;
|
||||
final SyncLogRepository _syncLog;
|
||||
final void Function()? _onSyncStart;
|
||||
final void Function()? _onSyncEnd;
|
||||
|
||||
bool _running = false;
|
||||
int _backoffSeconds = 5;
|
||||
Completer<void>? _stopSignal;
|
||||
Timer? _waitTimer;
|
||||
|
||||
static const _pollInterval = Duration(seconds: 30);
|
||||
|
||||
@@ -366,6 +481,7 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
Future<void> _loop() async {
|
||||
while (_running) {
|
||||
final startedAt = DateTime.now();
|
||||
_onSyncStart?.call();
|
||||
try {
|
||||
final (_SyncStats stats, String? capturedLog) = await _runSync(
|
||||
account.verbose,
|
||||
@@ -385,8 +501,10 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
protocolLog: capturedLog,
|
||||
);
|
||||
_backoffSeconds = 5;
|
||||
_onSyncEnd?.call();
|
||||
await _wait();
|
||||
} catch (e, st) {
|
||||
_onSyncEnd?.call();
|
||||
final isPermanent = _isPermanentError(e);
|
||||
try {
|
||||
await _syncLog.log(
|
||||
@@ -427,6 +545,7 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
}
|
||||
|
||||
bool _isPermanentError(Object e) {
|
||||
if (isTlsConfigError(e)) return true;
|
||||
final s = e.toString().toLowerCase();
|
||||
return s.contains('invalid credentials') ||
|
||||
s.contains('authentication failed') ||
|
||||
@@ -438,11 +557,16 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
Future<void> _waitSeconds(int seconds) async {
|
||||
if (!_running) return;
|
||||
_stopSignal = Completer<void>();
|
||||
await Future.any([
|
||||
Future.delayed(Duration(seconds: seconds)),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
_stopSignal = null;
|
||||
_waitTimer = Timer(Duration(seconds: seconds), () {
|
||||
if (!_stopSignal!.isCompleted) _stopSignal!.complete();
|
||||
});
|
||||
try {
|
||||
await _stopSignal!.future;
|
||||
} finally {
|
||||
_waitTimer?.cancel();
|
||||
_waitTimer = null;
|
||||
_stopSignal = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<(_SyncStats, String?)> _runSync(bool verbose) async {
|
||||
@@ -477,6 +601,7 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
final mailboxStats = <MailboxSyncStats>[];
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!_running) break;
|
||||
final mailboxStart = DateTime.now();
|
||||
final r = await _emails.syncEmails(account.id, mailbox.path);
|
||||
emailResult += r;
|
||||
mailboxStats.add(
|
||||
@@ -485,9 +610,11 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
fetched: r.fetched,
|
||||
skipped: r.skipped,
|
||||
bytesTransferred: r.bytesTransferred,
|
||||
duration: DateTime.now().difference(mailboxStart),
|
||||
),
|
||||
);
|
||||
}
|
||||
await _emails.applySieveRules(account.id);
|
||||
return _SyncStats(
|
||||
emailsFetched: emailResult.fetched,
|
||||
emailsSkipped: emailResult.skipped,
|
||||
@@ -514,11 +641,16 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
onError: (_) {},
|
||||
);
|
||||
|
||||
await Future.any([
|
||||
pushReady.future,
|
||||
Future.delayed(_pollInterval),
|
||||
_stopSignal!.future,
|
||||
]);
|
||||
final pollTimer = Timer(_pollInterval, () {
|
||||
if (_stopSignal != null && !_stopSignal!.isCompleted) {
|
||||
_stopSignal!.complete();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Future.any([pushReady.future, _stopSignal!.future]);
|
||||
} finally {
|
||||
pollTimer.cancel();
|
||||
}
|
||||
|
||||
await pushSub.cancel();
|
||||
_stopSignal = null;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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: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/repositories/account_repository.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 _kResourceType = 'background_check';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
Workmanager().executeTask((_, __) async {
|
||||
try {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
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> _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;
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ class ReliabilityRunner {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runForAccount(String accountId) async {
|
||||
Future<void> _runForAccount(String accountId, {bool force = false}) async {
|
||||
try {
|
||||
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
|
||||
var totalMissingLocally = 0;
|
||||
@@ -59,7 +59,7 @@ class ReliabilityRunner {
|
||||
final details = <String, dynamic>{};
|
||||
|
||||
for (final mailbox in mailboxes) {
|
||||
if (!_running) break;
|
||||
if (!force && !_running) break;
|
||||
final result = await _emails.verifySyncReliability(
|
||||
accountId,
|
||||
mailbox.path,
|
||||
@@ -103,7 +103,14 @@ class ReliabilityRunner {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
await _runAll();
|
||||
final accounts = await _accounts.observeAccounts().first;
|
||||
for (final account in accounts) {
|
||||
await _runForAccount(account.id, force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
@@ -2,3 +2,21 @@ bool isLocalhost(String host) {
|
||||
final h = host.trim().toLowerCase();
|
||||
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
|
||||
}
|
||||
|
||||
String? validateHostname(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return 'Required';
|
||||
return _checkHostChars(value.trim());
|
||||
}
|
||||
|
||||
String? validateOptionalHostname(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
return _checkHostChars(value.trim());
|
||||
}
|
||||
|
||||
String? _checkHostChars(String h) {
|
||||
if (h.contains(RegExp(r'[@/\\]')) ||
|
||||
h.codeUnits.any((c) => c < 32 || c == 127)) {
|
||||
return 'Invalid hostname';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+184
-10
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -88,6 +89,9 @@ class Emails extends Table {
|
||||
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
||||
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
||||
|
||||
// Added in schema v23: RFC 2369 List-Unsubscribe header value.
|
||||
TextColumn get listUnsubscribeHeader => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -104,6 +108,8 @@ class EmailBodies extends Table {
|
||||
DateTimeColumn get cachedAt => dateTime().nullable()();
|
||||
// Added in schema v20: raw or parsed headers
|
||||
TextColumn get headersJson => text().nullable()();
|
||||
// Added in schema v28: serialised MimePart tree (JSON)
|
||||
TextColumn get mimeTreeJson => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {emailId};
|
||||
@@ -199,6 +205,8 @@ class SyncLogMailboxes extends Table {
|
||||
IntColumn get fetched => integer().withDefault(const Constant(0))();
|
||||
IntColumn get skipped => 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.
|
||||
@@ -227,6 +235,44 @@ class Drafts extends Table {
|
||||
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
||||
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
||||
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')
|
||||
@@ -242,6 +288,21 @@ class UndoActions extends Table {
|
||||
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};
|
||||
}
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@DriftDatabase(
|
||||
@@ -258,16 +319,57 @@ class UndoActions extends Table {
|
||||
SyncLogMailboxes,
|
||||
SyncHealth,
|
||||
UndoActions,
|
||||
SearchHistoryEntries,
|
||||
LocalSieveScripts,
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 22;
|
||||
int get schemaVersion => 32;
|
||||
|
||||
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
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) async {
|
||||
await m.createAll();
|
||||
await _createEmailFts();
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// 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
|
||||
@@ -420,6 +522,54 @@ 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -429,20 +579,44 @@ String? _dbPath;
|
||||
|
||||
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
|
||||
/// 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 {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
_dbPath = p.join(dir.path, 'sharedinbox.db');
|
||||
} on PlatformException {
|
||||
// Channel not yet established; LazyDatabase will resolve the path
|
||||
// on first access, after runApp() completes initialization.
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the application support path, retrying on PlatformException to
|
||||
/// survive a race where the path_provider Pigeon channel isn't ready yet.
|
||||
Future<String> _resolveDatabasePath() async {
|
||||
if (_dbPath != null) return _dbPath!;
|
||||
// initDatabasePath() failed (channel not ready before runApp). Retry now
|
||||
// that the engine is fully initialised, with brief back-off.
|
||||
const delays = [100, 300, 600];
|
||||
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));
|
||||
}
|
||||
}
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'path_provider unavailable after ${delays.length + 1} attempts — '
|
||||
'cannot open database.',
|
||||
);
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final file = File(
|
||||
_dbPath ??
|
||||
p.join(
|
||||
(await getApplicationSupportDirectory()).path,
|
||||
'sharedinbox.db',
|
||||
),
|
||||
);
|
||||
final file = File(await _resolveDatabasePath());
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,52 @@ class TlsModeMismatchException implements Exception {
|
||||
'STARTTLS). Original error: $original';
|
||||
}
|
||||
|
||||
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
||||
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
||||
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
||||
/// Wraps a TLS certificate verification failure into a user-actionable message.
|
||||
///
|
||||
/// Thrown when the server's certificate cannot be verified — either because it
|
||||
/// 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) {
|
||||
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
||||
final s = error.toString();
|
||||
if (s.contains('WRONG_VERSION_NUMBER')) {
|
||||
Error.throwWithStackTrace(
|
||||
TlsModeMismatchException(host, port, error),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
|
||||
class DraftRepositoryImpl implements DraftRepository {
|
||||
DraftRepositoryImpl(this._db);
|
||||
DraftRepositoryImpl(
|
||||
this._db,
|
||||
this._accounts, {
|
||||
ImapConnectFn? imapConnect,
|
||||
}) : _imapConnect = imapConnect;
|
||||
|
||||
final AppDatabase _db;
|
||||
final AccountRepository _accounts;
|
||||
final ImapConnectFn? _imapConnect;
|
||||
|
||||
@override
|
||||
Future<SavedDraft> saveDraft({
|
||||
@@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
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(
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
@@ -104,5 +218,6 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
subjectText: row.subjectText,
|
||||
bodyText: row.bodyText,
|
||||
updatedAt: row.updatedAt,
|
||||
imapServerId: row.imapServerId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||
import 'package:sharedinbox/core/repositories/account_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/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
@@ -58,15 +62,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
@override
|
||||
Stream<List<model.Email>> observeEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) {
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) {
|
||||
return (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||
..limit(limit))
|
||||
.watch()
|
||||
.map((rows) => rows.map(_toModel).toList());
|
||||
}
|
||||
@@ -74,15 +80,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
@override
|
||||
Stream<List<model.EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) {
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) {
|
||||
return (_db.select(_db.threads)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.latestDate)]))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.latestDate)])
|
||||
..limit(limit))
|
||||
.watch()
|
||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||
}
|
||||
@@ -231,7 +239,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||
final msg = fetch.messages.first;
|
||||
final textBody = msg.decodeTextPlainPart();
|
||||
final htmlBody = msg.decodeTextHtmlPart();
|
||||
final rawHtml = msg.decodeTextHtmlPart();
|
||||
final htmlBody =
|
||||
rawHtml == null ? null : injectInlineImages(rawHtml, msg);
|
||||
final contentInfos = msg.findContentInfo();
|
||||
|
||||
final attachmentsJson = jsonEncode(
|
||||
@@ -255,6 +265,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.toList(),
|
||||
);
|
||||
|
||||
final mimeTreeJson = _buildMimeTreeJson(msg);
|
||||
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
@@ -262,6 +274,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: Value(htmlBody),
|
||||
attachmentsJson: Value(attachmentsJson),
|
||||
headersJson: Value(headersJson),
|
||||
mimeTreeJson: Value(mimeTreeJson),
|
||||
cachedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
@@ -271,6 +284,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: htmlBody,
|
||||
attachments: _parseAttachments(attachmentsJson),
|
||||
headers: _parseHeaders(headersJson),
|
||||
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
@@ -307,9 +321,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
'htmlBody',
|
||||
'bodyValues',
|
||||
'attachments',
|
||||
'bodyStructure',
|
||||
],
|
||||
'fetchHTMLBodyValues': true,
|
||||
'fetchTextBodyValues': true,
|
||||
'bodyProperties': [
|
||||
'partId',
|
||||
'type',
|
||||
'name',
|
||||
'size',
|
||||
'subParts',
|
||||
],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
@@ -329,6 +351,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
final rawBodyStructure =
|
||||
emailData['bodyStructure'] as Map<String, dynamic>?;
|
||||
final mimeTreeJson = rawBodyStructure != null
|
||||
? jsonEncode(_jmapBodyStructureToJson(rawBodyStructure))
|
||||
: null;
|
||||
|
||||
await _db.into(_db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
@@ -336,6 +364,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: Value(htmlBody),
|
||||
attachmentsJson: Value(attachmentsJson),
|
||||
headersJson: Value(headersJson),
|
||||
mimeTreeJson: Value(mimeTreeJson),
|
||||
cachedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
@@ -346,6 +375,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: htmlBody,
|
||||
attachments: _parseAttachments(attachmentsJson),
|
||||
headers: _parseHeaders(headersJson),
|
||||
mimeTree: _parseMimeTree(mimeTreeJson),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -528,7 +558,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
imap.MessageSequence sequence,
|
||||
) async {
|
||||
const fetchItems =
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES LIST-UNSUBSCRIBE)])';
|
||||
final fetch = sequence.isUidSequence
|
||||
? await client.uidFetchMessages(sequence, fetchItems)
|
||||
: await client.fetchMessages(sequence, fetchItems);
|
||||
@@ -569,6 +599,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final msgId = envelope.messageId?.trim();
|
||||
final inReplyTo = envelope.inReplyTo?.trim();
|
||||
final refs = msg.getHeaderValue('References')?.trim();
|
||||
final listUnsubscribe = msg.getHeaderValue('List-Unsubscribe')?.trim();
|
||||
final threadId = _computeThreadId(
|
||||
emailId: emailId,
|
||||
messageId: msgId,
|
||||
@@ -612,6 +643,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
inReplyTo: Value(inReplyTo),
|
||||
references: Value(refs),
|
||||
snoozedUntil: Value(snoozedUntil),
|
||||
listUnsubscribeHeader: Value(listUnsubscribe),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -950,6 +982,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
'htmlBody',
|
||||
'bodyValues',
|
||||
'attachments',
|
||||
'header:List-Unsubscribe:asText',
|
||||
];
|
||||
|
||||
static const _emailGetBodyOptions = {
|
||||
@@ -1151,6 +1184,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final jmapReferences = _joinJmapStringList(
|
||||
m['references'] as List<dynamic>?,
|
||||
);
|
||||
final jmapListUnsubscribe =
|
||||
(m['header:List-Unsubscribe:asText'] as String?)?.trim();
|
||||
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
@@ -1173,6 +1208,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
inReplyTo: Value(jmapInReplyTo),
|
||||
references: Value(jmapReferences),
|
||||
snoozedUntil: Value(snoozedUntil),
|
||||
listUnsubscribeHeader: Value(jmapListUnsubscribe),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1438,7 +1474,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
@@ -1510,12 +1547,70 @@ 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
|
||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
if (row.mailboxPath == destMailboxPath) {
|
||||
@@ -1583,7 +1678,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final row = await (_db.select(
|
||||
_db.emails,
|
||||
)..where((t) => t.id.equals(emailId)))
|
||||
.getSingle();
|
||||
.getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
final account = (await _accounts.getAccount(row.accountId))!;
|
||||
|
||||
// Move to Trash when possible so the user can recover the message.
|
||||
@@ -1777,6 +1873,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
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
|
||||
Future<void> restoreEmails(List<model.Email> emails) async {
|
||||
for (final e in emails) {
|
||||
@@ -1808,6 +1920,218 @@ 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.
|
||||
/// Called at the start of each sync cycle. Returns count of applied changes.
|
||||
@override
|
||||
@@ -1923,7 +2247,18 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.go();
|
||||
applied++;
|
||||
} catch (e) {
|
||||
await _recordChangeError(row, e);
|
||||
if (_isImapNotFoundError(e)) {
|
||||
// Email already gone on the server — treat as success so the
|
||||
// pending change doesn't accumulate or block future changes.
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.id.equals(row.id)))
|
||||
.go();
|
||||
applied++;
|
||||
log('IMAP change ${row.id} skipped: message already gone ($e)');
|
||||
} else {
|
||||
await _recordChangeError(row, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -1932,13 +2267,19 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
return applied;
|
||||
}
|
||||
|
||||
bool _isImapNotFoundError(Object e) {
|
||||
final s = e.toString().toLowerCase();
|
||||
return s.contains('nonexistent') || s.contains('not found');
|
||||
}
|
||||
|
||||
Future<void> _applyPendingChangeImap(
|
||||
imap.ImapClient client,
|
||||
PendingChangeRow row,
|
||||
) async {
|
||||
final payload = jsonDecode(row.payload) as Map<String, dynamic>;
|
||||
final uid = payload['uid'] as int;
|
||||
final mailboxPath = payload['mailboxPath'] as String;
|
||||
// snooze/unsnooze payloads use 'src' for the source folder; all others use 'mailboxPath'.
|
||||
final mailboxPath = (payload['mailboxPath'] ?? payload['src']) as String;
|
||||
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
|
||||
@@ -2077,8 +2418,29 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final until = payload['until'] as String;
|
||||
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
||||
final keyword = 'snz:$timestamp';
|
||||
final destMailboxId = payload['dest'] as String;
|
||||
var destMailboxId = payload['dest'] 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([
|
||||
[
|
||||
'Email/set',
|
||||
@@ -2442,9 +2804,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
// Fetch the full message so enough_mail has MIME headers (including
|
||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||
final fetch = await client.uidFetchMessage(
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[${attachment.fetchPartId}]',
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
final msg = fetch.messages.first;
|
||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||
@@ -2459,33 +2825,103 @@ 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[]',
|
||||
);
|
||||
return fetch.messages.first.renderMessage();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<model.Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
String query,
|
||||
) 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
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
final rows = await (_db.select(_db.emails)
|
||||
..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();
|
||||
if (words.isEmpty) return '';
|
||||
return words.map((w) => '$w*').join(' ');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2511,6 +2947,52 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
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()}%';
|
||||
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();
|
||||
|
||||
final seen = <String>{};
|
||||
final results = <model.EmailAddress>[];
|
||||
final lowerQuery = query.toLowerCase();
|
||||
for (final row in rows) {
|
||||
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
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
|
||||
Future<List<model.Email>> searchEmails(
|
||||
String accountId,
|
||||
@@ -2663,6 +3145,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
references: row.references,
|
||||
snoozedUntil: row.snoozedUntil,
|
||||
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
||||
listUnsubscribeHeader: row.listUnsubscribeHeader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2672,6 +3155,27 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
htmlBody: row.htmlBody,
|
||||
attachments: _parseAttachments(row.attachmentsJson),
|
||||
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) {
|
||||
@@ -2739,4 +3243,60 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
const PendingChangesCompanion(attempts: Value(0), lastError: Value(null)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {
|
||||
// Disable FK constraints so EmailBodies rows survive the emails deletion.
|
||||
// When emails are re-inserted after the next sync with the same IDs, the
|
||||
// cached body content will be reused without a network round-trip.
|
||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(_db.emails)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(_db.pendingChanges)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(_db.syncStates)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
});
|
||||
} finally {
|
||||
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
};
|
||||
|
||||
@@ -303,4 +303,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
if (mb.isJunk) return 'junk';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {
|
||||
await (_db.delete(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
|
||||
/// Drift-backed implementation of [ShareKeyRepository].
|
||||
///
|
||||
/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a
|
||||
/// new key pair is created or looked up.
|
||||
class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
ShareKeyRepositoryImpl(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial> createKeyPair() async {
|
||||
await _pruneExpired();
|
||||
|
||||
final material = await ShareEncryptionService.generateKeyPair();
|
||||
final keyIdHex = _hex(material.keyId);
|
||||
final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
|
||||
await _db.into(_db.shareKeys).insert(
|
||||
ShareKeysCompanion.insert(
|
||||
id: keyIdHex,
|
||||
publicKey: base64.encode(material.publicKeyBytes),
|
||||
privateKey: base64.encode(material.privateKeyBytes),
|
||||
expiresAt: expiresAt,
|
||||
),
|
||||
);
|
||||
|
||||
return material;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId) async {
|
||||
await _pruneExpired();
|
||||
|
||||
final keyIdHex = _hex(keyId);
|
||||
final row = await (_db.select(_db.shareKeys)
|
||||
..where((t) => t.id.equals(keyIdHex)))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (row == null) return null;
|
||||
if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null;
|
||||
|
||||
return ShareKeyMaterial(
|
||||
keyId: keyId,
|
||||
publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)),
|
||||
privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pruneExpired() async {
|
||||
await (_db.delete(_db.shareKeys)
|
||||
..where(
|
||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
static String _hex(Uint8List bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
fetched: Value(s.fetched),
|
||||
skipped: Value(s.skipped),
|
||||
bytesTransferred: Value(s.bytesTransferred),
|
||||
durationMs: Value(s.duration?.inMilliseconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -90,6 +91,9 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
fetched: m.fetched,
|
||||
skipped: m.skipped,
|
||||
bytesTransferred: m.bytesTransferred,
|
||||
duration: m.durationMs != null
|
||||
? Duration(milliseconds: m.durationMs!)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -99,4 +103,14 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
return entries;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String?> observeLastError(String accountId) {
|
||||
return (_db.select(_db.syncLogs)
|
||||
..where((t) => t.accountId.equals(accountId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.startedAt)])
|
||||
..limit(1))
|
||||
.watchSingleOrNull()
|
||||
.map((row) => (row?.result == 'error') ? row?.errorMessage : null);
|
||||
}
|
||||
}
|
||||
|
||||
+59
-2
@@ -3,26 +3,33 @@ import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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/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/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/undo_repository.dart';
|
||||
import 'package:sharedinbox/core/services/account_discovery_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/notification_service.dart';
|
||||
import 'package:sharedinbox/core/services/undo_service.dart';
|
||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/core/sync/account_sync_manager.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;
|
||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_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/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/undo_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||
@@ -56,6 +63,10 @@ final accountRepositoryProvider = Provider<AccountRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final shareKeyRepositoryProvider = Provider<ShareKeyRepository>((ref) {
|
||||
return ShareKeyRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
||||
return MailboxRepositoryImpl(
|
||||
ref.watch(dbProvider),
|
||||
@@ -65,7 +76,11 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
|
||||
});
|
||||
|
||||
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
|
||||
return DraftRepositoryImpl(ref.watch(dbProvider));
|
||||
return DraftRepositoryImpl(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(accountRepositoryProvider),
|
||||
imapConnect: ref.watch(imapConnectProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
|
||||
@@ -81,10 +96,20 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final searchHistoryRepositoryProvider =
|
||||
Provider<SearchHistoryRepository>((ref) {
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLogRepositoryProvider = Provider((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLastErrorProvider =
|
||||
StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
|
||||
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
|
||||
});
|
||||
|
||||
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
|
||||
final runner = ReliabilityRunner(
|
||||
ref.watch(dbProvider),
|
||||
@@ -105,6 +130,11 @@ final syncHealthProvider =
|
||||
.watchSingleOrNull();
|
||||
});
|
||||
|
||||
final isSyncingProvider =
|
||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||
});
|
||||
|
||||
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
||||
final manager = AccountSyncManager(
|
||||
ref.watch(accountRepositoryProvider),
|
||||
@@ -112,6 +142,8 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
|
||||
ref.watch(emailRepositoryProvider),
|
||||
syncLog: ref.watch(syncLogRepositoryProvider),
|
||||
imapConnect: ref.watch(imapConnectProvider),
|
||||
drafts: ref.watch(draftRepositoryProvider),
|
||||
onNewMail: showNewMailNotification,
|
||||
);
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
@@ -130,6 +162,10 @@ final sieveRepositoryProvider = Provider<SieveRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final localSieveRepositoryProvider = Provider<LocalSieveRepository>((ref) {
|
||||
return LocalSieveRepository(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
|
||||
return ConnectionTestServiceImpl(
|
||||
ref.watch(httpClientProvider),
|
||||
@@ -151,6 +187,27 @@ final undoServiceProvider =
|
||||
return service;
|
||||
});
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
||||
.family<EmailDetailNotifier, (Email?, EmailBody), String>(
|
||||
EmailDetailNotifier.new,
|
||||
);
|
||||
|
||||
class EmailDetailNotifier
|
||||
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
||||
@override
|
||||
Future<(Email?, EmailBody)> build(String emailId) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
repo.getEmail(emailId),
|
||||
repo.getEmailBody(emailId),
|
||||
]);
|
||||
unawaited(repo.setFlag(emailId, seen: true));
|
||||
return (results[0] as Email?, results[1] as EmailBody);
|
||||
}
|
||||
}
|
||||
|
||||
final accountByIdProvider =
|
||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||
|
||||
+8
-1
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/di.dart';
|
||||
import 'package:sharedinbox/ui/router.dart';
|
||||
@@ -32,6 +35,10 @@ void main({List<Override> overrides = const []}) async {
|
||||
};
|
||||
|
||||
await initDatabasePath();
|
||||
if (Platform.isAndroid) {
|
||||
await initNotifications();
|
||||
await registerBackgroundSync();
|
||||
}
|
||||
runApp(
|
||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||
);
|
||||
@@ -63,7 +70,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: 'SharedInbox',
|
||||
title: 'sharedinbox.de',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'package:go_router/go_router.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_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/address_emails_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
@@ -33,6 +36,14 @@ final router = GoRouter(
|
||||
path: 'add',
|
||||
builder: (ctx, state) => const AddAccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'receive',
|
||||
builder: (ctx, state) => const AccountReceiveScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'send',
|
||||
builder: (ctx, state) => const AccountSendScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
@@ -41,6 +52,10 @@ final router = GoRouter(
|
||||
path: 'changelog',
|
||||
builder: (ctx, state) => const ChangeLogScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'about',
|
||||
builder: (ctx, state) => const AboutScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
@@ -65,6 +80,21 @@ final router = GoRouter(
|
||||
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(
|
||||
path: ':accountId/search',
|
||||
builder: (ctx, state) =>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/di.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 Stream<List<Account>> _accountsStream;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||
}
|
||||
|
||||
String _buildMarkdown(
|
||||
BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version =
|
||||
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
return '## sharedinbox.de\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
static String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
Future<void> _copyToClipboard(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) async {
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return;
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createIssue(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) async {
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return;
|
||||
final body = Uri.encodeComponent(
|
||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
);
|
||||
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: _buildMarkdown(
|
||||
context,
|
||||
snapshot.data,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
launchUrl(
|
||||
Uri.parse(href),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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 to clipboard'),
|
||||
onPressed: () => unawaited(
|
||||
_copyToClipboard(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Create issue'),
|
||||
onPressed: () => unawaited(
|
||||
_createIssue(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/services/update_service.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AccountListScreen extends ConsumerWidget {
|
||||
const AccountListScreen({super.key});
|
||||
@@ -14,7 +15,7 @@ class AccountListScreen extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('SharedInbox'),
|
||||
title: const Text('sharedinbox.de'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
@@ -29,10 +30,18 @@ class AccountListScreen extends ConsumerWidget {
|
||||
const DrawerHeader(
|
||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||
child: Text(
|
||||
'SharedInbox',
|
||||
'sharedinbox.de',
|
||||
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(
|
||||
leading: const Icon(Icons.history),
|
||||
title: const Text('Undo Log'),
|
||||
@@ -49,37 +58,39 @@ class AccountListScreen extends ConsumerWidget {
|
||||
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'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: StreamBuilder(
|
||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final accounts = snap.data!;
|
||||
if (accounts.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('No accounts yet.'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/accounts/add'),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||
);
|
||||
},
|
||||
body: Column(
|
||||
children: [
|
||||
const _UpdateBanner(),
|
||||
Expanded(
|
||||
child: StreamBuilder(
|
||||
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final accounts = snap.data!;
|
||||
if (accounts.isEmpty) {
|
||||
return const _OnboardingView();
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/accounts/add'),
|
||||
@@ -159,15 +170,27 @@ class _AccountTile extends ConsumerWidget {
|
||||
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.emailFilters,
|
||||
child: Text('Email filters'),
|
||||
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,
|
||||
@@ -194,16 +217,53 @@ class _AccountTile extends ConsumerWidget {
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Starting sync verification...')),
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Starting sync verification...'),
|
||||
),
|
||||
);
|
||||
}
|
||||
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:
|
||||
await context.push('/accounts/${account.id}/edit');
|
||||
break;
|
||||
case _AccountAction.emailFilters:
|
||||
case _AccountAction.emailFiltersRemote:
|
||||
await context.push('/accounts/${account.id}/sieve');
|
||||
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:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -233,7 +293,122 @@ class _AccountTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
||||
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].
|
||||
///
|
||||
@@ -245,3 +420,31 @@ bool _sieveSupported(Account account) {
|
||||
if (account.type == AccountType.jmap) return true;
|
||||
return account.manageSieveAvailable != false;
|
||||
}
|
||||
|
||||
/// Shown on Linux desktop when a newer build is available on the server.
|
||||
class _UpdateBanner extends ConsumerWidget {
|
||||
const _UpdateBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final update = ref.watch(updateInfoProvider);
|
||||
return update.when(
|
||||
data: (info) {
|
||||
if (info == null) return const SizedBox.shrink();
|
||||
return MaterialBanner(
|
||||
content: Text('Update available: ${info.latestVersion}'),
|
||||
leading: const Icon(Icons.system_update),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
unawaited(launchUrl(Uri.parse(info.downloadUrl))),
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
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;
|
||||
String? _pubKeyQr;
|
||||
String? _errorMessage;
|
||||
bool _scannerActive = false;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
|
||||
@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;
|
||||
_pubKeyQr = qr;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_step = _Step.error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startScanning() {
|
||||
setState(() {
|
||||
_step = _Step.scanning;
|
||||
_scannerActive = true;
|
||||
_scannerController = MobileScannerController();
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
const _ExpiryHint(),
|
||||
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) {
|
||||
// On platforms where the camera scanner is not available (Linux desktop),
|
||||
// fall back to a text-input field.
|
||||
if (!_cameraScanSupported()) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
|
||||
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 StatelessWidget {
|
||||
const _ExpiryHint();
|
||||
|
||||
@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 20 minutes',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_cameraScanSupported()) {
|
||||
_scannerController = MobileScannerController();
|
||||
}
|
||||
}
|
||||
|
||||
@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()) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -295,6 +295,13 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
onPressed: _detectAccount,
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -408,7 +415,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
_field(_passwordCtrl, 'Password', obscure: true),
|
||||
const Divider(height: 32),
|
||||
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
|
||||
_field(_imapHostCtrl, 'Host'),
|
||||
_field(_imapHostCtrl, 'Host', validator: validateHostname),
|
||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
if (isLocalhost(_imapHostCtrl.text.trim()))
|
||||
SwitchListTile(
|
||||
@@ -418,7 +425,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||
_field(_smtpHostCtrl, 'Host'),
|
||||
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
|
||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||
SwitchListTile(
|
||||
@@ -475,6 +482,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
bool obscure = false,
|
||||
bool required = true,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
@@ -486,9 +494,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null,
|
||||
validator: validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChangeLogScreen extends StatelessWidget {
|
||||
|
||||
@@ -39,6 +39,8 @@ class ComposeScreen extends ConsumerStatefulWidget {
|
||||
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
final _to = TextEditingController();
|
||||
final _cc = TextEditingController();
|
||||
final _toFocus = FocusNode();
|
||||
final _ccFocus = FocusNode();
|
||||
final _subject = TextEditingController();
|
||||
final _body = TextEditingController();
|
||||
String? _accountId;
|
||||
@@ -139,6 +141,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
c.removeListener(_onTextChanged);
|
||||
c.dispose();
|
||||
}
|
||||
_toFocus.dispose();
|
||||
_ccFocus.dispose();
|
||||
// Flush any pending save synchronously — we can't await in dispose, but
|
||||
// scheduling a microtask still runs before the isolate exits.
|
||||
if (_draftDirty) {
|
||||
@@ -192,7 +196,12 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to open file: $e')));
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to open file: $e'),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _opening = false);
|
||||
}
|
||||
@@ -206,7 +215,12 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (_accountId == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Select an account first')));
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Select an account first'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _sending = true);
|
||||
@@ -243,7 +257,12 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Send failed: $e')));
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Send failed: $e'),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
@@ -315,8 +334,8 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
|
||||
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
|
||||
_addressField(_to, _toFocus, 'To'),
|
||||
_addressField(_cc, _ccFocus, 'Cc'),
|
||||
_field(_subject, 'Subject'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
@@ -369,6 +388,96 @@ 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(
|
||||
TextEditingController ctrl,
|
||||
String label, {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class CrashScreen extends StatelessWidget {
|
||||
@@ -12,6 +15,20 @@ class CrashScreen extends StatelessWidget {
|
||||
final Object exception;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
version = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {}
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
return 'App Version: $version\n'
|
||||
'Platform: $platform\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
@@ -20,39 +37,22 @@ class CrashScreen extends StatelessWidget {
|
||||
title: const Text('Something went wrong'),
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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) ...[
|
||||
body: Builder(
|
||||
builder: (ctx) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 64),
|
||||
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: 24),
|
||||
const Text(
|
||||
'Stack Trace:',
|
||||
'Error Details:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -63,64 +63,94 @@ class CrashScreen extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
stackTrace.toString(),
|
||||
exception.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final data = 'Error: $exception\n\nStack Trace:\n$stackTrace';
|
||||
await Clipboard.setData(ClipboardData(text: data));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied to clipboard')),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to Clipboard'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final title = Uri.encodeComponent(
|
||||
'Crash: ${exception.toString().split('\n').first}',
|
||||
);
|
||||
final body = Uri.encodeComponent(
|
||||
'Error: $exception\n\nStack Trace:\n$stackTrace',
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
if (stackTrace != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Stack Trace:',
|
||||
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(
|
||||
stackTrace.toString(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final data = await _buildReport();
|
||||
await Clipboard.setData(ClipboardData(text: data));
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not open browser.'),
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy to Clipboard'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -230,11 +230,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Edit account')),
|
||||
body: _loading
|
||||
body: _loading || _saving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _saving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildForm(),
|
||||
: _buildForm(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,11 +286,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
'IMAP (SSL/TLS)',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
_field(_imapHostCtrl, 'Host'),
|
||||
_field(_imapHostCtrl, 'Host', validator: validateHostname),
|
||||
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
const Divider(height: 32),
|
||||
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
|
||||
_field(_smtpHostCtrl, 'Host'),
|
||||
_field(_smtpHostCtrl, 'Host', validator: validateHostname),
|
||||
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
|
||||
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
||||
SwitchListTile(
|
||||
@@ -312,6 +310,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_sieveHostCtrl,
|
||||
'Host (leave blank to use IMAP host)',
|
||||
required: false,
|
||||
validator: validateOptionalHostname,
|
||||
),
|
||||
_field(
|
||||
_sievePortCtrl,
|
||||
@@ -363,6 +362,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
bool obscure = false,
|
||||
bool required = true,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
@@ -375,9 +375,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null,
|
||||
validator: validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
|
||||
: null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||
|
||||
@@ -25,144 +31,153 @@ class EmailDetailScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
late final Future<(Email?, EmailBody)> _dataFuture;
|
||||
bool _isFlagged = false;
|
||||
bool _loadRemoteImages = false;
|
||||
final Set<String> _downloading = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
_dataFuture = Future.wait([
|
||||
repo.getEmail(widget.emailId),
|
||||
repo.getEmailBody(widget.emailId),
|
||||
]).then((results) {
|
||||
final email = results[0] as Email?;
|
||||
if (email != null && mounted) {
|
||||
setState(() => _isFlagged = email.isFlagged);
|
||||
}
|
||||
return (email, results[1] as EmailBody);
|
||||
});
|
||||
unawaited(repo.setFlag(widget.emailId, seen: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
return FutureBuilder<(Email?, EmailBody)>(
|
||||
future: _dataFuture,
|
||||
builder: (ctx, snap) {
|
||||
final header = snap.data?.$1;
|
||||
final body = snap.data?.$2;
|
||||
final detail = ref.watch(emailDetailProvider(widget.emailId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
header?.subject ?? '(loading…)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
||||
emailDetailProvider(widget.emailId),
|
||||
(_, next) {
|
||||
final email = next.valueOrNull?.$1;
|
||||
if (email != null && mounted) {
|
||||
setState(() => _isFlagged = email.isFlagged);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final header = detail.valueOrNull?.$1;
|
||||
final body = detail.valueOrNull?.$2;
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !isMobile,
|
||||
title: Text(
|
||||
header?.subject ?? '(loading…)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply),
|
||||
tooltip: 'Reply',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_reply(context, header, body, replyAll: false));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply_all),
|
||||
tooltip: 'Reply all',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_reply(context, header, body, replyAll: true));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||
tooltip: 'Mark as unread',
|
||||
onPressed: () async {
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFlagged ? Icons.star : Icons.star_border,
|
||||
color: _isFlagged ? Colors.amber : null,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply),
|
||||
tooltip: 'Reply',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () => _reply(context, header, body, replyAll: false),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply_all),
|
||||
tooltip: 'Reply all',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () => _reply(context, header, body, replyAll: true),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () => _forward(context, header, body),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||
tooltip: 'Mark as unread',
|
||||
onPressed: () async {
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFlagged ? Icons.star : Icons.star_border,
|
||||
color: _isFlagged ? Colors.amber : null,
|
||||
),
|
||||
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
||||
onPressed: () async {
|
||||
final next = !_isFlagged;
|
||||
await repo.setFlag(widget.emailId, flagged: next);
|
||||
if (mounted) setState(() => _isFlagged = next);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_move_outline),
|
||||
tooltip: 'Move to folder',
|
||||
onPressed:
|
||||
header == null ? null : () => _moveTo(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
tooltip: 'Snooze',
|
||||
onPressed:
|
||||
header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
tooltip: _isFlagged ? 'Unflag' : 'Flag',
|
||||
onPressed: () async {
|
||||
final next = !_isFlagged;
|
||||
await repo.setFlag(widget.emailId, flagged: next);
|
||||
if (mounted) setState(() => _isFlagged = next);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_move_outline),
|
||||
tooltip: 'Move to folder',
|
||||
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (header != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
}
|
||||
},
|
||||
const PopupMenuItem(
|
||||
value: 'structure',
|
||||
child: Text('Show Mail Structure'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'rfc',
|
||||
child: Text('Show Raw Email'),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
} else if (value == 'structure' && body != null) {
|
||||
_showStructure(context, body);
|
||||
} else if (value == 'rfc') {
|
||||
unawaited(_showRaw(context, header));
|
||||
}
|
||||
},
|
||||
),
|
||||
body: snap.connectionState == ConnectionState.waiting
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: snap.hasError
|
||||
? Center(child: Text('Error: ${snap.error}'))
|
||||
: _buildBody(ctx, header, body!),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,9 +200,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Html(
|
||||
data: body.htmlBody!,
|
||||
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -267,30 +282,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
_dateFmt.format(email.sentAt!),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
if (email.listUnsubscribeHeader != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _UnsubscribeChip(header: email.listUnsubscribeHeader!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _quotedBody(Email header, EmailBody? body) {
|
||||
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
||||
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
||||
final from =
|
||||
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
||||
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
|
||||
final rawText = body?.textBody;
|
||||
final text = (rawText != null && rawText.isNotEmpty)
|
||||
? rawText
|
||||
: await compute(htmlToPlain, body?.htmlBody ?? '');
|
||||
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||
}
|
||||
|
||||
void _reply(
|
||||
Future<void> _reply(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body, {
|
||||
required bool replyAll,
|
||||
}) {
|
||||
}) async {
|
||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||
? header.subject!
|
||||
: 'Re: ${header.subject ?? ''}';
|
||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
@@ -298,23 +323,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
'replyToEmailId': widget.emailId,
|
||||
'prefillTo': to,
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': _quotedBody(header, body),
|
||||
'prefillBody': quoted,
|
||||
if (cc.isNotEmpty) 'prefillCc': cc,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _forward(BuildContext context, Email header, EmailBody? body) {
|
||||
Future<void> _forward(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body,
|
||||
) async {
|
||||
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
||||
? header.subject!
|
||||
: 'Fwd: ${header.subject ?? ''}';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
extra: {
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': _quotedBody(header, body),
|
||||
'prefillBody': quoted,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -394,6 +425,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Snoozed until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||
),
|
||||
@@ -403,10 +435,121 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRaw(BuildContext context, Email? header) async {
|
||||
final String raw;
|
||||
try {
|
||||
raw = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.fetchRawRfc822(widget.emailId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Raw Email'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fmtSize(raw.length),
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(ctx).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
raw,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: raw));
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(content: Text('Copied to clipboard')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _downloadRaw(ctx, header, raw);
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadRaw(
|
||||
BuildContext context,
|
||||
Email? header,
|
||||
String raw,
|
||||
) async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final subject = (header?.subject ?? 'email')
|
||||
.replaceAll(RegExp(r'[^\w\s-]'), '_')
|
||||
.trim();
|
||||
final filename = '$subject.eml';
|
||||
final file = File('${dir.path}/$filename');
|
||||
await file.writeAsString(raw);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Saved $filename'),
|
||||
action: SnackBarAction(
|
||||
label: 'Share',
|
||||
onPressed: () => SharePlus.instance.share(
|
||||
ShareParams(files: [XFile(file.path)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showHeaders(BuildContext context, EmailBody body) {
|
||||
if (body.headers.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('No headers available. Try re-syncing the email.'),
|
||||
),
|
||||
);
|
||||
@@ -460,20 +603,119 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
void _showStructure(BuildContext context, EmailBody body) {
|
||||
final tree = body.mimeTree;
|
||||
if (tree == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Structure not available. Try re-syncing the email.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
final rows = <_MimeRow>[];
|
||||
_flattenMimeTree(tree, 0, rows);
|
||||
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Mail Structure'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final row = rows[i];
|
||||
return Container(
|
||||
color: i.isEven
|
||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
||||
: Theme.of(ctx).colorScheme.surface,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: row.depth * 16.0),
|
||||
Expanded(
|
||||
child: Text(
|
||||
row.label,
|
||||
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MimeRow {
|
||||
const _MimeRow(this.depth, this.label);
|
||||
final int depth;
|
||||
final String label;
|
||||
}
|
||||
|
||||
void _flattenMimeTree(MimePart part, int depth, List<_MimeRow> out) {
|
||||
final parts = <String>[part.contentType];
|
||||
if (part.filename != null) parts.add('"${part.filename}"');
|
||||
if (part.size != null) parts.add(fmtSize(part.size!));
|
||||
if (part.encoding != null) parts.add(part.encoding!);
|
||||
out.add(_MimeRow(depth, parts.join(' ')));
|
||||
for (final child in part.children) {
|
||||
_flattenMimeTree(child, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a List-Unsubscribe header and returns the first usable URI.
|
||||
/// Prefers mailto: so unsubscribing sends an email; falls back to https:.
|
||||
Uri? _parseUnsubscribeUri(String header) {
|
||||
final matches = RegExp(r'<([^>]+)>').allMatches(header);
|
||||
Uri? fallback;
|
||||
for (final m in matches) {
|
||||
final raw = m.group(1)!.trim();
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri == null) continue;
|
||||
if (uri.scheme == 'mailto') return uri;
|
||||
if ((uri.scheme == 'https' || uri.scheme == 'http') && fallback == null) {
|
||||
fallback = uri;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class _UnsubscribeChip extends StatelessWidget {
|
||||
const _UnsubscribeChip({required this.header});
|
||||
final String header;
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
Widget build(BuildContext context) {
|
||||
final uri = _parseUnsubscribeUri(header);
|
||||
if (uri == null) return const SizedBox.shrink();
|
||||
return ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,19 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
|
||||
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 {
|
||||
const EmailListScreen({
|
||||
@@ -35,12 +44,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
bool _searchLoading = false;
|
||||
bool get _searching => _searchController.text.isNotEmpty;
|
||||
|
||||
// Error banner — tracks the last error message that the user dismissed.
|
||||
String? _dismissedError;
|
||||
|
||||
// Thread-level selection (key = threadId).
|
||||
final Set<String> _selectedThreadIds = {};
|
||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||
List<EmailThread> _currentThreads = [];
|
||||
// Individual email selection used in search results.
|
||||
final Set<String> _selectedSearchIds = {};
|
||||
|
||||
// Pagination: number of threads currently requested from the DB.
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@@ -78,6 +94,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
_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) {
|
||||
setState(() {
|
||||
if (_selectedSearchIds.contains(emailId)) {
|
||||
@@ -131,9 +157,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
currentMailboxPath: widget.mailboxPath,
|
||||
),
|
||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||
body: (_searchResults != null || _searchLoading)
|
||||
? _buildSearchBody()
|
||||
: _buildStreamBody(repo),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildSyncErrorBanner(),
|
||||
Expanded(
|
||||
child: (_searchResults != null || _searchLoading)
|
||||
? _buildSearchBody()
|
||||
: _buildStreamBody(repo),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +188,13 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
? Text('$selectionCount selected')
|
||||
: Text(widget.mailboxPath),
|
||||
actions: _selecting
|
||||
? []
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
tooltip: 'Select all',
|
||||
onPressed: _selectAll,
|
||||
),
|
||||
]
|
||||
: [
|
||||
accountAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
@@ -170,22 +209,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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')));
|
||||
}
|
||||
},
|
||||
),
|
||||
_buildSyncButton(emailRepo),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => context.push(
|
||||
@@ -193,6 +217,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
@@ -219,6 +259,47 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSyncButton(EmailRepository emailRepo) {
|
||||
final isSyncing =
|
||||
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
|
||||
final hasError =
|
||||
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != 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 _selectionBottomBar() {
|
||||
return BottomAppBar(
|
||||
child: Row(
|
||||
@@ -267,6 +348,45 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
return _buildEmailList(_searchResults!);
|
||||
}
|
||||
|
||||
Widget _buildSyncErrorBanner() {
|
||||
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
|
||||
final error = errorAsync.valueOrNull;
|
||||
if (error == null || error == _dismissedError) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return MaterialBanner(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
content: Text(
|
||||
error,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: Icon(
|
||||
Icons.sync_problem,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push(
|
||||
'/accounts/${widget.accountId}/sync-log',
|
||||
),
|
||||
child: const Text('View log'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _dismissedError = error),
|
||||
child: const Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStreamBody(EmailRepository emailRepo) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
@@ -276,7 +396,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
||||
},
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
|
||||
stream: emailRepo.observeThreads(
|
||||
widget.accountId,
|
||||
widget.mailboxPath,
|
||||
limit: _limit,
|
||||
),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -306,7 +430,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
if (mailbox == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(notFoundMessage)));
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(notFoundMessage),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
@@ -337,8 +466,36 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
Future<void> _batchArchive() =>
|
||||
_batchMoveToRole('archive', 'No archive folder found');
|
||||
|
||||
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 {
|
||||
final ids = _selectedEmailIds;
|
||||
final wasSearching = _searching;
|
||||
_clearSelection();
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
@@ -365,6 +522,25 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
originalEmails: originalEmails,
|
||||
);
|
||||
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() =>
|
||||
@@ -464,6 +640,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||
),
|
||||
@@ -472,9 +649,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Widget _buildThreadList(List<EmailThread> threads) {
|
||||
final hasMore = threads.length == _limit;
|
||||
return ListView.builder(
|
||||
itemCount: threads.length,
|
||||
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == threads.length) {
|
||||
return TextButton(
|
||||
onPressed: () => setState(() => _limit += _pageSize),
|
||||
child: const Text('Load more'),
|
||||
);
|
||||
}
|
||||
final t = threads[i];
|
||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||
final senderNames =
|
||||
@@ -543,7 +727,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_dateFmt.format(t.latestDate),
|
||||
_fmtDate(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -645,10 +829,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = emails[i];
|
||||
final isSelected = _selectedSearchIds.contains(e.id);
|
||||
final sender = e.from.isNotEmpty
|
||||
? (e.from.first.name ?? e.from.first.email)
|
||||
: '(unknown)';
|
||||
return ListTile(
|
||||
return EmailTile(
|
||||
email: e,
|
||||
selected: isSelected,
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
child: _selecting
|
||||
@@ -656,31 +839,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
value: isSelected,
|
||||
onChanged: (_) => _toggleSearchSelection(e.id),
|
||||
)
|
||||
: 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,
|
||||
: null,
|
||||
),
|
||||
onTap: _selecting
|
||||
? () => _toggleSearchSelection(e.id)
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
: () => unawaited(_openSearchResultAndRefresh(e.id)),
|
||||
onLongPress: () => _toggleSearchSelection(e.id),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,17 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
|
||||
final _searchHistoryProvider =
|
||||
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||
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 {
|
||||
const SearchScreen({super.key, this.accountId});
|
||||
@@ -19,13 +30,24 @@ class SearchScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
Timer? _debounce;
|
||||
_SearchResults? _results;
|
||||
bool _loading = false;
|
||||
bool _fieldFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(() {
|
||||
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -44,6 +66,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
|
||||
Future<void> _search(String query) async {
|
||||
setState(() => _loading = true);
|
||||
unawaited(
|
||||
ref
|
||||
.read(searchHistoryRepositoryProvider)
|
||||
.saveSearch(query)
|
||||
.then((_) => ref.invalidate(_searchHistoryProvider)),
|
||||
);
|
||||
try {
|
||||
final emailRepo = ref.read(emailRepositoryProvider);
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
@@ -56,7 +84,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
).wait;
|
||||
|
||||
final matchedMailboxes = allMailboxes
|
||||
.where((m) => m.name.toLowerCase().contains(ql))
|
||||
.where((m) => _hasWordPrefix(m.name, ql))
|
||||
.toList()
|
||||
..sort(compareMailboxes);
|
||||
|
||||
@@ -68,8 +96,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
for (final addr in [...email.from, ...email.to, ...email.cc]) {
|
||||
final key = '${email.accountId}:${addr.email}';
|
||||
if (seen.contains(key)) continue;
|
||||
final matchesEmail = addr.email.toLowerCase().contains(ql);
|
||||
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
|
||||
final matchesEmail = _hasWordPrefix(addr.email, ql);
|
||||
final matchesName =
|
||||
addr.name != null && _hasWordPrefix(addr.name!, ql);
|
||||
if (!matchesEmail && !matchesName) continue;
|
||||
seen.add(key);
|
||||
final addrEmail = addr.email;
|
||||
@@ -111,6 +140,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search folders, addresses, emails…',
|
||||
@@ -136,6 +166,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
Widget _buildBody() {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_results == null) {
|
||||
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||
return _buildHistoryPanel();
|
||||
}
|
||||
return const Center(child: Text('Type 3+ characters to search'));
|
||||
}
|
||||
final r = _results!;
|
||||
@@ -155,11 +188,79 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
if (r.emails.isNotEmpty) ...[
|
||||
const _SectionHeader('Messages'),
|
||||
for (final e in r.emails)
|
||||
_EmailTile(email: e, accountId: e.accountId),
|
||||
EmailTile(
|
||||
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 {
|
||||
@@ -246,42 +347,3 @@ 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)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
super.key,
|
||||
required this.accountId,
|
||||
this.script,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
final String accountId;
|
||||
@@ -18,6 +19,9 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
/// Null when creating a new script.
|
||||
final SieveScript? script;
|
||||
|
||||
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
ConsumerState<SieveScriptEditScreen> createState() =>
|
||||
_SieveScriptEditScreenState();
|
||||
@@ -50,9 +54,13 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
Future<void> _loadContent() async {
|
||||
setState(() => _loadingContent = true);
|
||||
try {
|
||||
final content = await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
final content = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
if (mounted) {
|
||||
_contentController.text = content;
|
||||
setState(() => _loadingContent = false);
|
||||
@@ -78,12 +86,21 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(sieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
content: _contentController.text,
|
||||
);
|
||||
if (widget.isLocal) {
|
||||
await ref.read(localSieveRepositoryProvider).saveScript(
|
||||
widget.accountId,
|
||||
id: widget.script?.id,
|
||||
name: name,
|
||||
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();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -8,10 +8,17 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class SieveScriptsScreen extends ConsumerStatefulWidget {
|
||||
const SieveScriptsScreen({super.key, required this.accountId});
|
||||
const SieveScriptsScreen({
|
||||
super.key,
|
||||
required this.accountId,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
final String accountId;
|
||||
|
||||
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
|
||||
final bool isLocal;
|
||||
|
||||
@override
|
||||
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
|
||||
}
|
||||
@@ -21,6 +28,10 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
String get _editRoute => widget.isLocal
|
||||
? '/accounts/${widget.accountId}/sieve/local/edit'
|
||||
: '/accounts/${widget.accountId}/sieve/edit';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -33,8 +44,13 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final scripts =
|
||||
await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
|
||||
final scripts = widget.isLocal
|
||||
? await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.listScripts(widget.accountId)
|
||||
: await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.listScripts(widget.accountId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scripts = scripts;
|
||||
@@ -53,15 +69,24 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
|
||||
Future<void> _activate(SieveScript script) async {
|
||||
try {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
if (widget.isLocal) {
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.activateScript(widget.accountId, script.id);
|
||||
}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to activate: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to activate: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,15 +111,24 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
);
|
||||
if (!(confirmed ?? false) || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
if (widget.isLocal) {
|
||||
await ref
|
||||
.read(localSieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(sieveRepositoryProvider)
|
||||
.deleteScript(widget.accountId, script.id);
|
||||
}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to delete: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,11 +136,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Email filters')),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await context.push('/accounts/${widget.accountId}/sieve/edit');
|
||||
await context.push(_editRoute);
|
||||
await _load();
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
@@ -134,22 +172,69 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
);
|
||||
}
|
||||
final scripts = _scripts ?? [];
|
||||
if (scripts.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No Sieve scripts. Tap + to create one.'),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: scripts.length,
|
||||
itemBuilder: (ctx, i) => _ScriptTile(
|
||||
script: scripts[i],
|
||||
accountId: widget.accountId,
|
||||
onActivate: () => _activate(scripts[i]),
|
||||
onDelete: () => _delete(scripts[i]),
|
||||
onEdited: _load,
|
||||
return Column(
|
||||
children: [
|
||||
_SieveSourceBanner(isLocal: widget.isLocal),
|
||||
Expanded(
|
||||
child: scripts.isEmpty
|
||||
? const Center(
|
||||
child: Text('No filters yet. Tap + to create one.'),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: scripts.length,
|
||||
itemBuilder: (ctx, i) => _ScriptTile(
|
||||
script: scripts[i],
|
||||
accountId: widget.accountId,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -159,6 +244,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
const _ScriptTile({
|
||||
required this.script,
|
||||
required this.accountId,
|
||||
required this.editRoute,
|
||||
required this.onActivate,
|
||||
required this.onDelete,
|
||||
required this.onEdited,
|
||||
@@ -166,6 +252,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
|
||||
final SieveScript script;
|
||||
final String accountId;
|
||||
final String editRoute;
|
||||
final VoidCallback onActivate;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onEdited;
|
||||
@@ -183,10 +270,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _ScriptAction.edit:
|
||||
await context.push(
|
||||
'/accounts/$accountId/sieve/edit',
|
||||
extra: script,
|
||||
);
|
||||
await context.push(editRoute, extra: script);
|
||||
onEdited();
|
||||
case _ScriptAction.activate:
|
||||
onActivate();
|
||||
@@ -209,7 +293,7 @@ class _ScriptTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
await context.push('/accounts/$accountId/sieve/edit', extra: script);
|
||||
await context.push(editRoute, extra: script);
|
||||
onEdited();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,6 +9,11 @@ import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||
|
||||
String _fmtDuration(Duration d) {
|
||||
final ms = d.inMilliseconds;
|
||||
return ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
||||
}
|
||||
|
||||
String _fmtBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
@@ -104,9 +109,7 @@ class _SyncLogTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ms = entry.duration.inMilliseconds;
|
||||
final durationLabel =
|
||||
ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
|
||||
final durationLabel = _fmtDuration(entry.duration);
|
||||
final proto =
|
||||
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
|
||||
final theme = Theme.of(context);
|
||||
@@ -154,7 +157,10 @@ class _SyncLogTile extends StatelessWidget {
|
||||
for (final m in entry.mailboxStats)
|
||||
_row(
|
||||
' ${m.mailboxPath}',
|
||||
'${m.fetched} new · ${m.skipped} up-to-date',
|
||||
[
|
||||
'${m.fetched} new · ${m.skipped} up-to-date',
|
||||
if (m.duration != null) _fmtDuration(m.duration!),
|
||||
].join(' · '),
|
||||
),
|
||||
],
|
||||
if (entry.errorMessage != null)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -10,6 +9,7 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||
|
||||
@@ -163,11 +163,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
Html(
|
||||
data: body.htmlBody!,
|
||||
extensions: [
|
||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||
],
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -248,6 +246,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (confirmed == true) {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
// Fetch data first for IMAP undo support
|
||||
@@ -255,6 +254,7 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
|
||||
final destPath = await repo.deleteEmail(widget.email.id);
|
||||
|
||||
if (!mounted) return;
|
||||
if (original != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
@@ -273,19 +273,3 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Action undone.')));
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Undo'),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
|
||||
/// A flat list tile for an individual [email].
|
||||
///
|
||||
/// Used in search-result lists and the per-mailbox search overlay.
|
||||
/// Pass a custom [leading] widget to support selection-mode checkboxes.
|
||||
class EmailTile extends StatelessWidget {
|
||||
const EmailTile({
|
||||
super.key,
|
||||
required this.email,
|
||||
required this.onTap,
|
||||
this.leading,
|
||||
this.selected = false,
|
||||
this.onLongPress,
|
||||
this.showLocation = false,
|
||||
});
|
||||
|
||||
final Email email;
|
||||
final VoidCallback onTap;
|
||||
final Widget? leading;
|
||||
final bool selected;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
/// When true, appends `accountId • mailboxPath` as a second subtitle line.
|
||||
final bool showLocation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sender = email.from.isNotEmpty
|
||||
? (email.from.first.name ?? email.from.first.email)
|
||||
: '(unknown)';
|
||||
final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : '';
|
||||
|
||||
return ListTile(
|
||||
leading: leading ??
|
||||
Icon(
|
||||
email.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style:
|
||||
email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
email.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (showLocation)
|
||||
Text(
|
||||
'${email.accountId} • ${email.mailboxPath}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: date.isEmpty
|
||||
? null
|
||||
: Text(date, style: Theme.of(context).textTheme.bodySmall),
|
||||
selected: selected,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,21 @@ class FolderDrawer extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_list),
|
||||
title: const Text('Email filters'),
|
||||
leading: const Icon(Icons.dns),
|
||||
title: const Text('Remote Filters'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/$accountId/sieve'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.phone_android),
|
||||
title: const Text('Local Filters'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/$accountId/sieve/local'));
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: StreamBuilder(
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Builds the full HTML document string for rendering an email body.
|
||||
///
|
||||
/// Forces `color-scheme: light` so that emails with black text remain readable
|
||||
/// when the device is in dark mode — the WebView would otherwise apply a dark
|
||||
/// background while leaving the email's own text colours unchanged.
|
||||
@visibleForTesting
|
||||
String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||
final imgSrc = loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
|
||||
// script-src 'none' blocks page scripts; JS mode stays unrestricted so the
|
||||
// controller can call runJavaScriptReturningResult for height measurement.
|
||||
const cspBase = "default-src 'none'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"script-src 'none'; "
|
||||
"object-src 'none'; "
|
||||
"font-src 'none'";
|
||||
final csp = '$cspBase; img-src $imgSrc';
|
||||
|
||||
return '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #1976D2; }
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
$htmlBody
|
||||
</body>
|
||||
</html>''';
|
||||
}
|
||||
|
||||
/// Renders an HTML email body securely.
|
||||
///
|
||||
/// On Android the content is displayed in a WebView with JavaScript blocked
|
||||
/// via CSP and remote images blocked until the user opts in. Link taps show
|
||||
/// a confirmation dialog that highlights the registered domain to aid phishing
|
||||
/// detection.
|
||||
///
|
||||
/// On Linux (where webview_flutter has no platform support) the HTML is
|
||||
/// converted to plain text and shown in a [SelectableText] widget.
|
||||
class SecureEmailWebView extends StatefulWidget {
|
||||
const SecureEmailWebView({
|
||||
super.key,
|
||||
required this.htmlBody,
|
||||
this.loadRemoteImages = false,
|
||||
});
|
||||
|
||||
final String htmlBody;
|
||||
final bool loadRemoteImages;
|
||||
|
||||
@override
|
||||
State<SecureEmailWebView> createState() => _SecureEmailWebViewState();
|
||||
}
|
||||
|
||||
class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
// Null on Linux where WebView is unavailable.
|
||||
WebViewController? _controller;
|
||||
double _height = 300;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!Platform.isLinux) {
|
||||
final c = WebViewController();
|
||||
unawaited(c.setJavaScriptMode(JavaScriptMode.unrestricted));
|
||||
unawaited(c.setBackgroundColor(Colors.transparent));
|
||||
unawaited(
|
||||
c.setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _handleNavigation,
|
||||
onPageFinished: _measureHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
unawaited(c.loadHtmlString(_buildHtml()));
|
||||
_controller = c;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SecureEmailWebView old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.htmlBody != widget.htmlBody ||
|
||||
old.loadRemoteImages != widget.loadRemoteImages) {
|
||||
if (_controller != null) {
|
||||
unawaited(_controller!.loadHtmlString(_buildHtml()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildHtml() => buildEmailHtml(
|
||||
widget.htmlBody,
|
||||
loadRemoteImages: widget.loadRemoteImages,
|
||||
);
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
}
|
||||
|
||||
NavigationDecision _handleNavigation(NavigationRequest req) {
|
||||
final url = req.url;
|
||||
if (url == 'about:blank' || url.startsWith('data:')) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
unawaited(_showLinkDialog(url));
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
Future<void> _showLinkDialog(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !mounted) return;
|
||||
|
||||
final host = uri.host;
|
||||
final parts = host.split('.');
|
||||
// Bold the registered domain (last two DNS labels) to aid phishing detection.
|
||||
final boldStart = (parts.length >= 2
|
||||
? host.length -
|
||||
parts.last.length -
|
||||
1 -
|
||||
parts[parts.length - 2].length
|
||||
: 0)
|
||||
.clamp(0, host.length);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Open link?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
children: [
|
||||
TextSpan(text: host.substring(0, boldStart)),
|
||||
TextSpan(
|
||||
text: host.substring(boldStart),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
url,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Open in browser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final launched =
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not open: $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Linux has no webview_flutter platform implementation — show plain text.
|
||||
if (Platform.isLinux) {
|
||||
return SelectableText(
|
||||
htmlToPlain(widget.htmlBody),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: _height,
|
||||
child: WebViewWidget(controller: _controller!),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ class UndoShell extends ConsumerWidget {
|
||||
ref.listen<List<UndoAction>>(undoServiceProvider, (previous, next) {
|
||||
if (next.isNotEmpty &&
|
||||
(previous == null || previous.length < next.length)) {
|
||||
_showUndoSnackbar(context, ref, next.last);
|
||||
final action = next.last;
|
||||
// Don't show a snackbar for actions loaded from persistence on app
|
||||
// startup — only for actions pushed in this session.
|
||||
if (DateTime.now().difference(action.timestamp).inSeconds < 30) {
|
||||
_showUndoSnackbar(context, ref, action);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +34,7 @@ class UndoShell extends ConsumerWidget {
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(
|
||||
action.type == UndoType.delete
|
||||
? '${action.emailIds.length} email(s) moved to Trash'
|
||||
|
||||
+1329
File diff suppressed because it is too large
Load Diff
+26
-7
@@ -40,10 +40,27 @@ dependencies:
|
||||
open_filex: ^4.6.0
|
||||
mime: ^2.0.0
|
||||
|
||||
# QR code generation for account sharing
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
# Public-key encryption for secure account sharing (ECIES: X25519 + AES-256-GCM)
|
||||
cryptography: ^2.7.0
|
||||
|
||||
# QR code scanning (camera) for secure account import
|
||||
mobile_scanner: ^5.0.0
|
||||
|
||||
# HTML rendering for email bodies
|
||||
flutter_html: ^3.0.0
|
||||
webview_flutter: ^4.0.0
|
||||
url_launcher: ^6.3.2
|
||||
flutter_markdown: ^0.7.7+1
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
|
||||
# Background sync and local notifications
|
||||
flutter_local_notifications: ^18.0.1
|
||||
workmanager: ^0.9.0
|
||||
|
||||
# App version metadata for crash reports
|
||||
package_info_plus: ^8.0.0
|
||||
share_plus: ^12.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -56,7 +73,8 @@ dev_dependencies:
|
||||
test: ^1.25.0
|
||||
mockito: ^5.4.4
|
||||
fake_async: ^1.3.1
|
||||
sqlite3: any # used directly in test/unit/db_test_helper.dart
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
@@ -66,7 +84,8 @@ flutter:
|
||||
- assets/
|
||||
|
||||
dependency_overrides:
|
||||
# path_provider_android 2.3+ uses package:jni which crashes on startup
|
||||
# (SIGSEGV in libdartjni.so FindClassUnchecked — JNI env not ready when
|
||||
# the Dart VM first calls into it). Pin to 2.2.x which uses Pigeon instead.
|
||||
path_provider_android: ">=2.2.0 <2.3.0"
|
||||
# path_provider_android 2.2.21 updated to Pigeon 26, which causes a
|
||||
# channel-error on startup on some Android devices. 2.3+ uses package:jni
|
||||
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
|
||||
# stable Pigeon and is known to work reliably.
|
||||
path_provider_android: ">=2.2.0 <2.2.21"
|
||||
|
||||
Executable
+585
@@ -0,0 +1,585 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
agent_loop.py — called from cron every 10 minutes.
|
||||
|
||||
Flow
|
||||
----
|
||||
1. Agent already running?
|
||||
a. Age > 1 h → kill it, set its issue to State/Question, exit 1
|
||||
b. Age ≤ 1 h → print status, exit 0 (let it keep working)
|
||||
2. No agent running → extract pending_issue from state (if any), then check CI
|
||||
a. CI is running → save pending-ci state, exit 0
|
||||
b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0
|
||||
c. CI ok + pending_issue → close the issue (CI passed), exit 0
|
||||
d. CI ok (or no run yet) → find oldest Ready issue, start issue agent,
|
||||
save state, exit 0
|
||||
e. No Ready issues → print "nothing to do", exit 0
|
||||
|
||||
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
|
||||
|
||||
State file: ~/.sharedinbox-agent-state.json
|
||||
{ "pid": 12345, "issue": 91,
|
||||
"started_at": "2026-05-15T12:00:00+00:00", "type": "issue" }
|
||||
|
||||
Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
|
||||
Resume the Claude conversation afterward with:
|
||||
|
||||
claude --resume issue-91
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
|
||||
os.environ["PATH"] = (
|
||||
f"{Path.home()}/.nix-profile/bin"
|
||||
f":{Path.home()}/go/bin"
|
||||
f":{os.environ.get('PATH', '/usr/bin:/bin')}"
|
||||
)
|
||||
|
||||
# ── configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
REPO = "guettli/sharedinbox"
|
||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||
"-" + str(Path.home())[1:].replace("/", "-")
|
||||
)
|
||||
|
||||
# Labels used by the workflow.
|
||||
LABEL_READY = "State/Ready"
|
||||
LABEL_IN_PROGRESS = "State/InProgress"
|
||||
LABEL_QUESTION = "State/Question"
|
||||
LABEL_PRIO_HIGH = "Prio/High"
|
||||
|
||||
# Only pick up issues filed by these accounts.
|
||||
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _issue_url(number: int) -> str:
|
||||
return f"{REPO_URL}/issues/{number}"
|
||||
|
||||
|
||||
def _ci_run_url(run_id: int) -> str:
|
||||
return f"{REPO_URL}/actions/runs/{run_id}"
|
||||
|
||||
|
||||
def _fgj(*args: str) -> None:
|
||||
"""Run a fgj command, raising on failure."""
|
||||
cmd = ["fgj", "--hostname", "codeberg.org", *args]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"fgj {' '.join(args)} failed:\n{result.stderr or result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def _tea_get(path: str) -> dict | list | None:
|
||||
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
|
||||
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
|
||||
cmd = ["tea", "api", path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"tea api {path} failed:\n{result.stderr or result.stdout}"
|
||||
)
|
||||
out = result.stdout.strip()
|
||||
if not out:
|
||||
return None
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict) and "message" in data and "url" in data:
|
||||
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
|
||||
return data
|
||||
|
||||
|
||||
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||
"""Add/remove labels on an issue via fgj."""
|
||||
cmd = ["issue", "edit", str(issue), "--repo", REPO]
|
||||
for label in add:
|
||||
cmd += ["--add-label", label]
|
||||
for label in remove:
|
||||
cmd += ["--remove-label", label]
|
||||
_fgj(*cmd)
|
||||
|
||||
|
||||
def _close_issue(issue: int) -> None:
|
||||
_fgj("issue", "close", str(issue), "--repo", REPO)
|
||||
_set_labels(issue, add=[], remove=[LABEL_IN_PROGRESS])
|
||||
|
||||
|
||||
def _comment_issue(issue: int, body: str) -> None:
|
||||
_fgj("issue", "comment", str(issue), "--repo", REPO, "--body", body)
|
||||
|
||||
|
||||
def _ready_issues() -> list[dict]:
|
||||
"""Return open issues with State/Ready, Prio/High first, then oldest."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "issue", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
ready = [
|
||||
i for i in data
|
||||
if any(lbl["name"] == LABEL_READY for lbl in i.get("labels", []))
|
||||
and i.get("user", {}).get("login", "") in ALLOWED_ISSUE_AUTHORS
|
||||
]
|
||||
ready.sort(key=lambda i: (
|
||||
0 if any(lbl["name"] == LABEL_PRIO_HIGH for lbl in i.get("labels", [])) else 1,
|
||||
i["number"],
|
||||
))
|
||||
return ready
|
||||
|
||||
|
||||
def _latest_ci_run() -> dict | None:
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=1")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
return runs[0] if runs else None
|
||||
|
||||
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | None:
|
||||
"""Return the latest CI run for a specific branch, or None."""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
if run.get("head_branch") == branch:
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_pr_for_branch(branch: str) -> dict | None:
|
||||
"""Return the first open PR whose head branch matches, or None."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "list",
|
||||
"--repo", REPO, "--state", "open", "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
prs = json.loads(result.stdout)
|
||||
for pr in prs:
|
||||
head = pr.get("head", {})
|
||||
ref = head.get("ref") or head.get("label", "").split(":")[-1]
|
||||
if ref == branch:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def _merge_pr(pr_number: int) -> None:
|
||||
"""Squash-merge a PR via fgj."""
|
||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||
|
||||
|
||||
# ── state file ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_state() -> dict | None:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(pid: int | None, issue: int | None, kind: str, issue_title: str | None = None, session_name: str | None = None, ci_run_id: int | None = None) -> None:
|
||||
data: dict = {
|
||||
"pid": pid,
|
||||
"issue": issue,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"type": kind,
|
||||
}
|
||||
if issue_title is not None:
|
||||
data["issue_title"] = issue_title
|
||||
if session_name is not None:
|
||||
data["session_name"] = session_name
|
||||
if ci_run_id is not None:
|
||||
data["ci_run_id_at_start"] = ci_run_id
|
||||
STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||
STATE_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ── agent launcher ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _start_agent(prompt: str, session_name: str) -> int:
|
||||
"""Start Claude Code as a detached background process and return its PID."""
|
||||
log_dir = Path.home() / ".sharedinbox-agent-logs"
|
||||
log_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
log_dir.chmod(0o700) # fix permissions if dir already existed with wrong mode
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
log_file = log_dir / f"{session_name}-{ts}.log"
|
||||
|
||||
log_fh = open(log_file, "w", opener=lambda p, f: os.open(p, f, 0o600))
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"claude",
|
||||
"--dangerously-skip-permissions",
|
||||
"--name", session_name,
|
||||
"-p", prompt,
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
start_new_session=True,
|
||||
)
|
||||
log_fh.close() # Parent closes its copy; the child retains the fd.
|
||||
# Answer the workspace-trust dialog; after this the pipe hits EOF.
|
||||
proc.stdin.write(b"\n")
|
||||
proc.stdin.close()
|
||||
|
||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f" Resume: claude --resume {shlex.quote(session_name)}")
|
||||
return proc.pid
|
||||
|
||||
|
||||
def _agent_alive(state: dict) -> bool:
|
||||
"""Return True if the agent process is still running."""
|
||||
pid = state.get("pid")
|
||||
if pid is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
|
||||
def _agent_age_seconds(state: dict) -> float:
|
||||
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
|
||||
try:
|
||||
started_at = datetime.fromisoformat(state["started_at"])
|
||||
return (datetime.now(timezone.utc) - started_at).total_seconds()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _git_summary() -> str:
|
||||
"""Return a one-line summary of the latest commit and whether it's been pushed."""
|
||||
try:
|
||||
commit = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
ahead = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD@{u}..HEAD"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if ahead.returncode == 0 and ahead.stdout.strip() != "0":
|
||||
push_status = f"not pushed ({ahead.stdout.strip()} ahead)"
|
||||
elif ahead.returncode == 0:
|
||||
push_status = "pushed"
|
||||
else:
|
||||
push_status = "no upstream"
|
||||
return f"{commit} [{push_status}]"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _kill_agent(state: dict) -> None:
|
||||
"""Forcefully stop the running agent."""
|
||||
pid = state.get("pid")
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
# ── subcommands ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
"""List recent agent-loop sessions, newest first."""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||
return 0
|
||||
|
||||
sessions = []
|
||||
for jsonl in CLAUDE_PROJECTS_DIR.glob("*.jsonl"):
|
||||
agent_name = None
|
||||
session_id = None
|
||||
try:
|
||||
with jsonl.open() as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "agent-name":
|
||||
agent_name = d.get("agentName")
|
||||
session_id = d.get("sessionId")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if agent_name:
|
||||
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
|
||||
|
||||
if not sessions:
|
||||
print("No agent sessions found.")
|
||||
return 0
|
||||
|
||||
sessions.sort(reverse=True)
|
||||
total = len(sessions)
|
||||
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
|
||||
print(f" {'-'*16} {'-'*20} {'-'*36}")
|
||||
for mtime, name, sid in sessions[:20]:
|
||||
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
||||
print(f" {ts:<16} {name:<20} {sid}")
|
||||
if total > 20:
|
||||
print(f" ... ({total - 20} more)")
|
||||
return 0
|
||||
|
||||
|
||||
# ── main flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_loop() -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||
|
||||
state = _read_state()
|
||||
|
||||
# ── 1. Agent already running? ─────────────────────────────────────────────
|
||||
if state and _agent_alive(state):
|
||||
age = _agent_age_seconds(state)
|
||||
issue = state.get("issue")
|
||||
kind = state.get("type", "issue")
|
||||
pid = state.get("pid", "?")
|
||||
|
||||
issue_title = state.get("issue_title", "")
|
||||
issue_ref = (
|
||||
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
|
||||
)
|
||||
|
||||
if age > MAX_AGENT_AGE_SECONDS:
|
||||
print(
|
||||
f"Agent pid={pid!r} ({issue_ref}) "
|
||||
f"has been running for {age/60:.0f} min — aborting."
|
||||
)
|
||||
_kill_agent(state)
|
||||
_clear_state()
|
||||
if issue:
|
||||
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
issue,
|
||||
f"Agent (pid {pid}) was killed after running for {age/60:.0f} min "
|
||||
f"(limit: {MAX_AGENT_AGE_SECONDS//60} min). "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
print(f"Set {_issue_url(issue)} to State/Question.")
|
||||
return 1
|
||||
|
||||
session_name = state.get("session_name")
|
||||
resume_cmd = f"claude --resume {shlex.quote(session_name)}" if session_name else ""
|
||||
git_info = _git_summary()
|
||||
parts = [
|
||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) still running ({age/60:.0f} min). Waiting.",
|
||||
]
|
||||
if resume_cmd:
|
||||
parts.append(f" Resume: {resume_cmd}")
|
||||
if git_info:
|
||||
parts.append(f" Commit: {git_info}")
|
||||
print("\n".join(parts))
|
||||
return 0
|
||||
|
||||
# Agent not running (or no state) — extract any pending issue, then clean up.
|
||||
pending_issue: int | None = None
|
||||
ci_run_id_at_start: int | None = None
|
||||
if state:
|
||||
pending_issue = state.get("issue")
|
||||
ci_run_id_at_start = state.get("ci_run_id_at_start")
|
||||
_clear_state()
|
||||
|
||||
# ── 2. Check for a PR opened by the agent ────────────────────────────────
|
||||
if pending_issue:
|
||||
branch = f"issue-{pending_issue}-fix"
|
||||
pr = _find_pr_for_branch(branch)
|
||||
if pr:
|
||||
pr_number = pr["number"]
|
||||
pr_url = f"{REPO_URL}/pulls/{pr_number}"
|
||||
print(f"Found PR #{pr_number} ({pr_url}) for issue #{pending_issue}.")
|
||||
pr_run = _latest_ci_run_for_branch(branch)
|
||||
|
||||
if pr_run and pr_run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} is running. Waiting.")
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
|
||||
if pr_run and pr_run.get("status") in ("failure", "error"):
|
||||
print(f"CI run {_ci_run_url(pr_run['id'])} on branch {branch!r} failed — starting fix agent.")
|
||||
prompt = (
|
||||
f"The Codeberg CI for guettli/sharedinbox just failed on branch {branch!r} "
|
||||
f"(PR #{pr_number}). "
|
||||
f"CI run: {_ci_run_url(pr_run['id'])}. "
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push to the same branch. "
|
||||
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"When done, stop."
|
||||
)
|
||||
session_name = f"ci-fix-pr-{pr_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name=session_name)
|
||||
return 0
|
||||
|
||||
if not pr_run:
|
||||
# No CI run yet — might be that CI hasn't triggered yet.
|
||||
# Wait up to 15 min before giving up.
|
||||
pr_created_at = pr.get("created_at", "")
|
||||
try:
|
||||
created = datetime.fromisoformat(pr_created_at.replace("Z", "+00:00"))
|
||||
age_s = (datetime.now(timezone.utc) - created).total_seconds()
|
||||
except Exception:
|
||||
age_s = 999999
|
||||
if age_s < 900:
|
||||
print(
|
||||
f"PR #{pr_number} has no CI run yet (created {age_s/60:.0f} min ago). Waiting."
|
||||
)
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
print(
|
||||
f"No CI run for branch {branch!r} after {age_s/60:.0f} min — "
|
||||
"agent may not have pushed. Setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
f"Agent opened PR #{pr_number} but no CI run appeared on branch `{branch}` "
|
||||
f"after {age_s/60:.0f} min. The agent may not have pushed any commits. "
|
||||
"Please investigate and resume manually.",
|
||||
)
|
||||
return 0
|
||||
|
||||
# CI passed on the PR branch — squash-merge and close.
|
||||
print(f"CI passed on branch {branch!r} — merging PR #{pr_number}.")
|
||||
_merge_pr(pr_number)
|
||||
_close_issue(pending_issue)
|
||||
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# ── 3. Global CI check (agent pushed to main, or no pending issue) ────────
|
||||
run = _latest_ci_run()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
if pending_issue:
|
||||
_write_state(None, pending_issue, "pending-ci")
|
||||
return 0
|
||||
|
||||
if run and run.get("status") in ("failure", "error"):
|
||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||
prompt = (
|
||||
"The Codeberg CI for guettli/sharedinbox just failed. "
|
||||
f"The CI run ID is {run['id']}. "
|
||||
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
|
||||
"Identify the failure, fix it, commit, and push. "
|
||||
"Verify locally with 'task check' before pushing. "
|
||||
"When done, stop."
|
||||
)
|
||||
pid = _start_agent(prompt, "ci-fix")
|
||||
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
|
||||
return 0
|
||||
|
||||
# CI is ok (or no run).
|
||||
if pending_issue:
|
||||
latest_run_id = run["id"] if run else None
|
||||
if ci_run_id_at_start is not None and latest_run_id == ci_run_id_at_start:
|
||||
# CI run hasn't changed since the agent was launched → agent pushed nothing
|
||||
# (likely crashed or hit a rate limit).
|
||||
print(
|
||||
f"No new CI run since agent started for {_issue_url(pending_issue)} "
|
||||
f"(run id {latest_run_id}) — agent did nothing. Setting to State/Question."
|
||||
)
|
||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
_comment_issue(
|
||||
pending_issue,
|
||||
"The agent exited without pushing any changes (no new CI run was triggered). "
|
||||
"This usually means the agent hit a rate limit or crashed at startup. "
|
||||
"The issue has been set to State/Question — please review the agent log and retry.",
|
||||
)
|
||||
return 0
|
||||
_close_issue(pending_issue)
|
||||
print(f"CI passed — closed {_issue_url(pending_issue)}.")
|
||||
return 0
|
||||
|
||||
# Find a Ready issue.
|
||||
issues = _ready_issues()
|
||||
if not issues:
|
||||
print("No issues with State/Ready. Nothing to do.")
|
||||
return 0
|
||||
|
||||
issue = issues[0]
|
||||
issue_number = issue["number"]
|
||||
issue_title = issue["title"]
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
||||
|
||||
# Mark InProgress before starting so the next cron tick sees it even if
|
||||
# the agent hasn't had time to do so yet.
|
||||
_set_labels(
|
||||
issue_number,
|
||||
add=[LABEL_IN_PROGRESS],
|
||||
remove=[LABEL_READY],
|
||||
)
|
||||
|
||||
prompt = f"""Work on Codeberg issue #{issue_number} in the guettli/sharedinbox repository.
|
||||
|
||||
Issue title: {issue_title}
|
||||
|
||||
Issue body:
|
||||
{issue_body}
|
||||
|
||||
Instructions:
|
||||
- Understand the issue thoroughly before writing any code.
|
||||
- Implement the required change, following the existing code style.
|
||||
- Write or update tests as appropriate.
|
||||
- Run 'task check' locally and fix any failures before committing.
|
||||
- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})").
|
||||
- Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main:
|
||||
git checkout -b issue-{issue_number}-fix
|
||||
git push -u origin issue-{issue_number}-fix
|
||||
fgj pr create --title "fix: <short description> (#{issue_number})" \\
|
||||
--head issue-{issue_number}-fix --base main --repo {REPO}
|
||||
- Do NOT push to main, do NOT close the issue, and do NOT merge the PR — the loop handles that after CI passes.
|
||||
- If you hit a blocker you cannot resolve, set the issue label to State/Question
|
||||
and stop (do NOT close the issue).
|
||||
- When the work is pushed and the PR is opened, stop. The loop will merge the PR and close the issue after CI passes.
|
||||
"""
|
||||
|
||||
session_name = f"issue-{issue_number}"
|
||||
pid = _start_agent(prompt, session_name)
|
||||
current_run_id = run["id"] if run else None
|
||||
_write_state(pid, issue_number, "issue", issue_title, session_name=session_name, ci_run_id=current_run_id)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="agent_loop")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
sub.add_parser("list", help="List recent agent sessions")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "list":
|
||||
return cmd_list()
|
||||
return _run_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -15,8 +15,10 @@ const _noCode = {
|
||||
'lib/core/repositories/draft_repository.dart',
|
||||
'lib/core/repositories/email_repository.dart',
|
||||
'lib/core/repositories/mailbox_repository.dart',
|
||||
'lib/core/repositories/share_key_repository.dart',
|
||||
'lib/core/repositories/sync_log_repository.dart',
|
||||
'lib/core/repositories/undo_repository.dart',
|
||||
'lib/core/repositories/search_history_repository.dart',
|
||||
'lib/core/models/undo_action.dart',
|
||||
'lib/core/storage/secure_storage.dart',
|
||||
};
|
||||
@@ -32,6 +34,8 @@ const _excluded = {
|
||||
'lib/main.dart',
|
||||
'lib/ui/router.dart',
|
||||
'lib/ui/screens/account_list_screen.dart',
|
||||
'lib/ui/screens/account_receive_screen.dart',
|
||||
'lib/ui/screens/account_send_screen.dart',
|
||||
'lib/ui/screens/add_account_screen.dart',
|
||||
'lib/ui/screens/address_emails_screen.dart',
|
||||
'lib/ui/screens/changelog_screen.dart',
|
||||
@@ -48,18 +52,25 @@ const _excluded = {
|
||||
'lib/ui/screens/thread_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_screen.dart',
|
||||
'lib/ui/widgets/folder_drawer.dart',
|
||||
'lib/ui/widgets/secure_email_webview.dart',
|
||||
'lib/ui/widgets/snooze_picker.dart',
|
||||
'lib/ui/widgets/try_connection_button.dart',
|
||||
'lib/ui/widgets/undo_shell.dart',
|
||||
'lib/ui/screens/about_screen.dart',
|
||||
'lib/ui/widgets/email_tile.dart',
|
||||
'lib/core/sync/account_sync_manager.dart',
|
||||
'lib/core/sync/background_sync.dart',
|
||||
'lib/core/sync/reliability_runner.dart',
|
||||
'lib/data/jmap/jmap_client.dart',
|
||||
'lib/data/jmap/sieve_repository.dart',
|
||||
'lib/data/repositories/account_repository_impl.dart',
|
||||
'lib/data/repositories/email_repository_impl.dart',
|
||||
'lib/data/repositories/mailbox_repository_impl.dart',
|
||||
'lib/data/repositories/share_key_repository_impl.dart',
|
||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||
'lib/data/repositories/undo_repository_impl.dart',
|
||||
'lib/data/repositories/search_history_repository_impl.dart',
|
||||
'lib/core/services/update_service.dart',
|
||||
};
|
||||
|
||||
void main() {
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that all *.mocks.dart files are up to date.
|
||||
# Re-runs build_runner and fails if any generated mock differs from what is committed.
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
echo "check-mocks: regenerating..."
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
if fvm flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1; then
|
||||
grep -vE '^\[' "$tmp" || true
|
||||
else
|
||||
cat "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANGED=$(git diff --name-only -- '*.mocks.dart')
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "ERROR: The following mock files are out of date:"
|
||||
echo "$CHANGED"
|
||||
echo "Run 'task codegen' and commit the regenerated mocks."
|
||||
exit 1
|
||||
fi
|
||||
echo "check-mocks: all mock files are up to date."
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fail if binary files (other than images and fonts) are staged for commit.
|
||||
# Prevents accidental inclusion of build artifacts, databases, compiled binaries.
|
||||
set -euo pipefail
|
||||
|
||||
ALLOWED_EXTENSIONS='(png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2)'
|
||||
|
||||
# git diff --numstat shows "- - path" for binary files
|
||||
BINARY=$(git diff --cached --numstat | awk '$1=="-" && $2=="-" {print $3}')
|
||||
|
||||
if [ -z "$BINARY" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BLOCKED=''
|
||||
while IFS= read -r f; do
|
||||
if ! echo "$f" | grep -qiE "\.$ALLOWED_EXTENSIONS$"; then
|
||||
BLOCKED="$BLOCKED\n $f"
|
||||
fi
|
||||
done <<< "$BINARY"
|
||||
|
||||
if [ -n "$BLOCKED" ]; then
|
||||
echo "Binary files staged for commit (not allowed):"
|
||||
echo -e "$BLOCKED"
|
||||
echo ""
|
||||
echo "If this is intentional, add the extension to ALLOWED_EXTENSIONS in scripts/check_no_binary.sh"
|
||||
exit 1
|
||||
fi
|
||||
+86
-25
@@ -4,14 +4,78 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
|
||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||
TRACK = "internal"
|
||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||
|
||||
|
||||
def _make_session(config_json: str) -> AuthorizedSession:
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
)
|
||||
return AuthorizedSession(creds)
|
||||
|
||||
|
||||
def _upload_aab(session: AuthorizedSession, edit_id: str) -> int:
|
||||
"""Resumable upload of the AAB. Returns the version code."""
|
||||
file_size = os.path.getsize(AAB_PATH)
|
||||
|
||||
with open(AAB_PATH, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
# Each attempt needs a fresh resumable upload URL — the previous URL expires on failure.
|
||||
init_resp = session.post(
|
||||
f"{_UPLOAD_BASE}/{PACKAGE_NAME}/edits/{edit_id}/bundles",
|
||||
params={"uploadType": "resumable"},
|
||||
headers={
|
||||
"X-Upload-Content-Type": "application/octet-stream",
|
||||
"X-Upload-Content-Length": str(file_size),
|
||||
},
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
if not init_resp.ok:
|
||||
print(f"Init attempt {attempt + 1} failed: HTTP {init_resp.status_code}: {init_resp.text[:500]}")
|
||||
init_resp.raise_for_status()
|
||||
upload_url = init_resp.headers["Location"]
|
||||
|
||||
upload_resp = session.put(
|
||||
upload_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(file_size),
|
||||
},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
if not upload_resp.ok:
|
||||
print(f"Upload attempt {attempt + 1} failed: HTTP {upload_resp.status_code}: {upload_resp.text[:500]}")
|
||||
upload_resp.raise_for_status()
|
||||
return upload_resp.json()["versionCode"]
|
||||
except requests.RequestException as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(f"Attempt {attempt + 1} failed ({exc}), retrying in {delay}s…")
|
||||
time.sleep(delay)
|
||||
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
|
||||
|
||||
def main():
|
||||
@@ -24,34 +88,31 @@ def main():
|
||||
print(f"Error: AAB not found at {AAB_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
creds = service_account.Credentials.from_service_account_info(
|
||||
json.loads(config_json),
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
session = _make_session(config_json)
|
||||
|
||||
edit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits",
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
edit_resp.raise_for_status()
|
||||
edit_id = edit_resp.json()["id"]
|
||||
|
||||
service = build("androidpublisher", "v3", credentials=creds)
|
||||
|
||||
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute()
|
||||
edit_id = edit["id"]
|
||||
|
||||
media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True)
|
||||
bundle = (
|
||||
service.edits()
|
||||
.bundles()
|
||||
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
||||
.execute()
|
||||
)
|
||||
version_code = bundle["versionCode"]
|
||||
version_code = _upload_aab(session, edit_id)
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
service.edits().tracks().update(
|
||||
packageName=PACKAGE_NAME,
|
||||
editId=edit_id,
|
||||
track=TRACK,
|
||||
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
).execute()
|
||||
tracks_resp = session.put(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
timeout=30,
|
||||
)
|
||||
tracks_resp.raise_for_status()
|
||||
|
||||
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute()
|
||||
commit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||
timeout=30,
|
||||
)
|
||||
commit_resp.raise_for_status()
|
||||
print(f"Deployed version {version_code} to {TRACK} track")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Hugo markdown pages listing builds fetched from the server.
|
||||
|
||||
Reads build artifacts under public_html/builds/ on the deployment server via SSH,
|
||||
parses the git hash from each filename, fetches the commit title from the
|
||||
Codeberg API, then writes Hugo content pages to website/content/builds/.
|
||||
|
||||
Covers two platforms:
|
||||
- Linux: sharedinbox-linux-amd64-<hash>.tar.gz
|
||||
- Android: sharedinbox-mua-<hash>.apk
|
||||
|
||||
At most MAX_BUILDS_PER_PLATFORM of the most-recent builds are shown per platform.
|
||||
|
||||
These generated pages are not tracked in git (see .gitignore).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
CODEBERG_REPO = "guettli/sharedinbox"
|
||||
REMOTE_BUILDS_DIR = "public_html/builds"
|
||||
CONTENT_DIR = Path("website/content/builds")
|
||||
BASE_URL = "https://sharedinbox.de"
|
||||
CODEBERG_BASE = "https://codeberg.org"
|
||||
MAX_BUILDS_PER_PLATFORM = 30
|
||||
|
||||
|
||||
def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-v",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-i", "/root/.ssh/id_ed25519",
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"WARNING: ssh exit {result.returncode} listing {pattern} on {ssh_user}@{ssh_host}"
|
||||
" — build history will be empty for this pattern",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return []
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def get_commit_info(hash_val: str) -> tuple[str, str]:
|
||||
"""Return (title, datetime_iso) for the given commit hash."""
|
||||
url = f"https://codeberg.org/api/v1/repos/{CODEBERG_REPO}/git/commits/{hash_val}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "sharedinbox-ci"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
title = data.get("commit", {}).get("message", "").split("\n")[0]
|
||||
dt = data.get("commit", {}).get("committer", {}).get("date", "")
|
||||
return title, dt
|
||||
except Exception as exc:
|
||||
print(f" warning: could not fetch commit info for {hash_val}: {exc}", file=sys.stderr)
|
||||
return hash_val, ""
|
||||
|
||||
|
||||
def parse_builds(
|
||||
paths: list[str],
|
||||
path_re: re.Pattern, # type: ignore[type-arg]
|
||||
) -> dict[str, list[tuple[str, str, str, str]]]:
|
||||
"""Parse build file paths into {date_key: [(hash, url, title, dt), ...]}."""
|
||||
limited = paths[-MAX_BUILDS_PER_PLATFORM:] if len(paths) > MAX_BUILDS_PER_PLATFORM else paths
|
||||
days: dict[str, list[tuple[str, str, str, str]]] = {}
|
||||
for path in limited:
|
||||
m = path_re.match(path)
|
||||
if not m:
|
||||
print(f" skipping unexpected path: {path}", file=sys.stderr)
|
||||
continue
|
||||
year, month, day, filename, hash_val = m.groups()
|
||||
date_key = f"{year}/{month}/{day}"
|
||||
download_url = f"{BASE_URL}/builds/{year}/{month}/{day}/{filename}"
|
||||
commit_title, commit_dt = get_commit_info(hash_val)
|
||||
days.setdefault(date_key, []).append((hash_val, download_url, commit_title, commit_dt))
|
||||
return days
|
||||
|
||||
|
||||
def render_entries(
|
||||
entries: list[tuple[str, str, str, str]],
|
||||
link_label: str,
|
||||
) -> str:
|
||||
lines = []
|
||||
for hash_val, download_url, commit_title, commit_dt in entries:
|
||||
commit_url = f"{CODEBERG_BASE}/{CODEBERG_REPO}/commit/{hash_val}"
|
||||
dt_str = f" · {commit_dt}" if commit_dt else ""
|
||||
lines.append(
|
||||
f"- [{commit_title}]({commit_url}){dt_str} \n"
|
||||
f" [{link_label}]({download_url}) (`{hash_val}`)\n"
|
||||
)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ssh_user = os.environ.get("SSH_USER", "")
|
||||
ssh_host = os.environ.get("SSH_HOST", "")
|
||||
if not ssh_user or not ssh_host:
|
||||
print("SSH_USER and SSH_HOST must be set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Listing Linux builds on {ssh_host}…")
|
||||
linux_paths = list_remote_files(ssh_user, ssh_host, "sharedinbox-linux-amd64-*.tar.gz")
|
||||
print(f"Found {len(linux_paths)} Linux build(s)")
|
||||
linux_re = re.compile(
|
||||
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-linux-amd64-(.+)\.tar\.gz)$"
|
||||
)
|
||||
linux_days = parse_builds(linux_paths, linux_re)
|
||||
|
||||
print(f"Listing Android APKs on {ssh_host}…")
|
||||
apk_paths = list_remote_files(ssh_user, ssh_host, "*.apk")
|
||||
print(f"Found {len(apk_paths)} APK(s)")
|
||||
apk_re = re.compile(
|
||||
r"public_html/builds/(\d{4})/(\d{2})/(\d{2})/(sharedinbox-mua-(.+)\.apk)$"
|
||||
)
|
||||
android_days = parse_builds(apk_paths, apk_re)
|
||||
|
||||
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# _index.md: platform sections, newest-first within each
|
||||
index_lines = ["---\ntitle: Builds\n---\n\n"]
|
||||
|
||||
index_lines.append(f"## Linux (last {MAX_BUILDS_PER_PLATFORM})\n\n")
|
||||
if linux_days:
|
||||
for date_key in sorted(linux_days, reverse=True):
|
||||
year, month, day = date_key.split("/")
|
||||
index_lines.append(f"### {year}-{month}-{day}\n\n")
|
||||
index_lines.append(render_entries(linux_days[date_key], "Download"))
|
||||
index_lines.append("\n")
|
||||
else:
|
||||
index_lines.append("_No Linux builds yet._\n\n")
|
||||
|
||||
index_lines.append(f"## Android (last {MAX_BUILDS_PER_PLATFORM})\n\n")
|
||||
if android_days:
|
||||
for date_key in sorted(android_days, reverse=True):
|
||||
year, month, day = date_key.split("/")
|
||||
index_lines.append(f"### {year}-{month}-{day}\n\n")
|
||||
index_lines.append(render_entries(android_days[date_key], "Download APK"))
|
||||
index_lines.append("\n")
|
||||
else:
|
||||
index_lines.append("_No Android builds yet._\n\n")
|
||||
|
||||
(CONTENT_DIR / "_index.md").write_text("".join(index_lines), encoding="utf-8")
|
||||
|
||||
# Per-day pages (combined)
|
||||
all_days = set(linux_days) | set(android_days)
|
||||
for date_key in sorted(all_days):
|
||||
year, month, day = date_key.split("/")
|
||||
date_iso = f"{year}-{month}-{day}"
|
||||
day_dir = CONTENT_DIR / year / month
|
||||
day_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lines = [f"---\ntitle: 'Builds for {date_iso}'\ndate: {date_iso}T00:00:00Z\n---\n\n"]
|
||||
if date_key in linux_days:
|
||||
lines.append("## Linux\n\n")
|
||||
lines.append(render_entries(linux_days[date_key], "Download"))
|
||||
lines.append("\n")
|
||||
if date_key in android_days:
|
||||
lines.append("## Android\n\n")
|
||||
lines.append(render_entries(android_days[date_key], "Download APK"))
|
||||
lines.append("\n")
|
||||
(day_dir / f"{day}.md").write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
total_linux = sum(len(v) for v in linux_days.values())
|
||||
total_android = sum(len(v) for v in android_days.values())
|
||||
print(
|
||||
f"Generated pages: {total_linux} Linux build(s), {total_android} Android build(s) "
|
||||
f"across {len(all_days)} day(s)"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the Firebase Test Lab Dagger pipeline with Gradle/Dagger noise filtered out.
|
||||
# Retries up to 3 times on transient Dagger engine connectivity errors.
|
||||
set -uo pipefail
|
||||
|
||||
OUT=$(mktemp)
|
||||
RC_FILE=$(mktemp)
|
||||
trap 'rm -f "$OUT" "$RC_FILE"' EXIT
|
||||
|
||||
_strip_ansi() {
|
||||
sed 's/\x1b\[[0-9;]*[mGKHFJ]//g'
|
||||
}
|
||||
|
||||
_filter_noise() {
|
||||
grep -vE \
|
||||
'> Task :.+(UP-TO-DATE|NO-SOURCE|SKIPPED)'\
|
||||
'|[0-9]+ files found for path '\''lib/'\
|
||||
'|^Inputs:'\
|
||||
'|^[[:space:]]+-[[:space:]]/'\
|
||||
'|\[Incubating\]'\
|
||||
'|Deprecated Gradle features'\
|
||||
'|warning-mode all'\
|
||||
'|please refer to https://docs\.gradle'\
|
||||
'|[0-9]+ actionable tasks'\
|
||||
'|^warning: \[options\]'\
|
||||
'|^Note: Some input files'\
|
||||
'|Starting a Gradle Daemon'\
|
||||
'|Have questions, feedback, or issues'\
|
||||
'|https://firebase\.google\.com/support'\
|
||||
'|^\s*[┆│]\s*$' \
|
||||
|| true
|
||||
}
|
||||
|
||||
_run() {
|
||||
: > "$OUT" ; : > "$RC_FILE"
|
||||
{
|
||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||
--project-id "$FIREBASE_PROJECT_ID"
|
||||
echo $? > "$RC_FILE"
|
||||
} 2>&1 | tee "$OUT" | _strip_ansi | _filter_noise
|
||||
}
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
_run && break
|
||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
|
||||
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||
else
|
||||
exit "$RC"
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$(cat "$RC_FILE" 2>/dev/null || echo 0)"
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Establishes a secure tunnel to a remote Dagger Engine via stunnel.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${DAGGER_STUNNEL_URL:-}" ]; then
|
||||
echo "Error: DAGGER_STUNNEL_URL must be set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse host and port (e.g., example.com:8774 or just example.com)
|
||||
host=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f1)
|
||||
port=$(echo "$DAGGER_STUNNEL_URL" | cut -d: -f2)
|
||||
if [ "$host" == "$port" ]; then
|
||||
port="8774"
|
||||
fi
|
||||
|
||||
echo "Probing $host:$port..."
|
||||
if ! nc -zw 3 "$host" "$port" 2>/dev/null; then
|
||||
echo "Error: No Dagger server responded on $host:$port"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found active Dagger server on $host:$port"
|
||||
|
||||
# 2. Setup TLS credentials (passed as env vars from secrets)
|
||||
mkdir -p /tmp/dagger-tls
|
||||
echo "$DAGGER_CA_CERT" > /tmp/dagger-tls/ca.crt
|
||||
echo "$DAGGER_CLIENT_CERT" > /tmp/dagger-tls/client.crt
|
||||
echo "$DAGGER_CLIENT_KEY" > /tmp/dagger-tls/client.key
|
||||
chmod 600 /tmp/dagger-tls/client.key
|
||||
|
||||
# 3. Configure and start stunnel
|
||||
STUNNEL_CONF="/tmp/stunnel-dagger.conf"
|
||||
cat << EOF > "$STUNNEL_CONF"
|
||||
client = yes
|
||||
foreground = yes
|
||||
pid = /tmp/stunnel.pid
|
||||
debug = warning
|
||||
; TCP keepalive on the remote side to prevent NAT/firewall from resetting the connection
|
||||
socket = r:SO_KEEPALIVE=1
|
||||
socket = r:TCP_KEEPIDLE=10
|
||||
socket = r:TCP_KEEPINTVL=5
|
||||
socket = r:TCP_KEEPCNT=3
|
||||
|
||||
[dagger]
|
||||
accept = 127.0.0.1:1774
|
||||
connect = $host:$port
|
||||
CAfile = /tmp/dagger-tls/ca.crt
|
||||
cert = /tmp/dagger-tls/client.crt
|
||||
key = /tmp/dagger-tls/client.key
|
||||
verifyChain = yes
|
||||
EOF
|
||||
|
||||
# Start stunnel in the background
|
||||
stunnel "$STUNNEL_CONF" &
|
||||
TUNNEL_PID=$!
|
||||
|
||||
# Give it a moment to establish
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||
echo "Error: stunnel failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Export environment for subsequent CI steps
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "_EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774" >> "$GITHUB_ENV"
|
||||
echo "Tunnel established. Dagger is configured to use the remote engine."
|
||||
else
|
||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774
|
||||
echo "Tunnel established. Run: export _DAGGER_RUNNER_HOST=tcp://127.0.0.1:1774"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user