Compare commits

..
Author SHA1 Message Date
Thomas Güttler e76851c893 Merge remote-tracking branch 'origin/main' into test-foo 2026-06-05 08:36:05 +02:00
Thomas Güttler c04764b565 test. 2026-06-05 08:33:13 +02:00
72 changed files with 684 additions and 2705 deletions
-20
View File
@@ -1,20 +0,0 @@
name: Chaos Monkey
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
chaos-monkey-backend:
name: Chaos Monkey (backend)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run backend chaos monkey
run: task chaos-monkey-backend
-1
View File
@@ -4,7 +4,6 @@ jobs:
check:
name: Full Project Check
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
-1
View File
@@ -10,7 +10,6 @@ jobs:
# Disabled until a self-hosted runner with label "windows-runner" is registered.
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
timeout-minutes: 90
if: false
steps:
+4 -4
View File
@@ -10,10 +10,10 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/guettli/sync-branch
rev: v0.0.11
- repo: https://github.com/guettli/pre-commit-branch-up-to-date
rev: v0.0.5
hooks:
- id: sync-branch
- id: branch-up-to-date
- repo: local
hooks:
@@ -32,7 +32,7 @@ repos:
- id: dart-check
name: dart format (autofix) + check-fast (parallel)
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
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
+46
View File
@@ -0,0 +1,46 @@
# Snooze Feature Plan
## Goal
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
## Technical Approach
### 1. Metadata Storage (Account Sync)
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
- **JMAP:** Use `keywords`.
- **IMAP:** Use User Flags (keywords).
### 2. Database Changes
- **Migration v22:**
- `Emails` table:
- `snoozedUntil` (DateTime, nullable)
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
- Index on `snoozedUntil`.
### 3. Repository Updates (`EmailRepository`)
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
- Optimistically update local DB.
- Enqueue `snooze` change.
- New method: `Future<int> wakeUpEmails(String accountId)`
- Find local rows where `snoozedUntil <= now`.
- Enqueue `move` back to original mailbox.
- Clear snooze metadata.
### 4. Sync Loop Integration
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
### 5. UI Implementation
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
## Implementation Steps
1. [ ] Database migration and model updates.
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
4. [ ] Update sync logic to parse snooze keywords.
5. [ ] Integrate `wakeUpEmails` into the sync loop.
6. [ ] UI: Snooze picker dialog.
7. [ ] UI: Add Snooze action to list and detail screens.
8. [ ] Testing and validation.
+5
View File
@@ -216,3 +216,8 @@ test/
- **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
+56 -49
View File
@@ -37,8 +37,6 @@ tasks:
run: once
deps: [_nix-check]
preconditions:
- sh: '[ "$(id -u)" != "0" ]'
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
- sh: test -n "${IN_NIX_SHELL}"
msg: "Not in nix dev shell. Run: nix develop"
cmds:
@@ -58,14 +56,6 @@ tasks:
cmds:
- echo "Setup complete."
generate-icons:
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
deps: [_pub-get]
cmds:
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
- fvm flutter pub run flutter_launcher_icons
generate-changelog:
desc: Generate assets/changelog.txt from git history
cmds:
@@ -106,19 +96,34 @@ tasks:
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
codegen:
desc: Generate Drift DB code via Dagger (exports generated files back to host)
desc: Generate Drift DB code (run after any schema change)
deps: [_preflight, _pub-get]
sources:
- lib/**/*.dart
- pubspec.yaml
generates:
- lib/**/*.g.dart
cmds:
- dagger call --progress=plain -q -m ci --source=. codegen -o .
- fvm flutter pub run build_runner build --delete-conflicting-outputs
analyze:
desc: Static analysis via Dagger (dart analyze --fatal-infos)
desc: Static analysis (flutter analyze)
deps: [_preflight, _codegen]
sources:
- lib/**/*.dart
- test/**/*.dart
- pubspec.yaml
- analysis_options.yaml
cmds:
- dagger call --progress=plain -q -m ci --source=. analyze
- scripts/run_analyze.sh
format:
desc: Format all Dart source files via Dagger (writes back to host)
desc: Format all Dart source files
deps: [_preflight]
sources:
- "**/*.dart"
cmds:
- dagger call --progress=plain -q -m ci --source=. format-write -o .
- fvm dart format lib test
check-mocks:
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
@@ -131,9 +136,13 @@ tasks:
- scripts/check_mocks_fresh.sh
analyze-fix:
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
desc: Auto-fix lint issues with dart fix --apply
deps: [_preflight]
sources:
- lib/**/*.dart
- test/**/*.dart
cmds:
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
- fvm dart fix --apply
test:
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
@@ -168,17 +177,17 @@ tasks:
test-backend:
desc: Backend tests against a local Stalwart mail server (via Dagger)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
- dagger call --progress=plain -q -m ci --source=. test-backend
integration-ui:
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
- dagger call --progress=plain -q -m ci --source=. test-integration
sync-reliability:
desc: Run sync reliability runner (via Dagger)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
- 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)
@@ -193,7 +202,7 @@ tasks:
ci-graph:
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
cmds:
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
- dagger call --progress=plain -q -m ci --source=. graph
stalwart:
desc: Start a Stalwart instance for local development (via Dagger)
@@ -209,13 +218,13 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds:
- mkdir -p build/app/outputs/bundle/release
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -225,7 +234,7 @@ tasks:
- sh: test -f build/app/outputs/bundle/release/app-release.aab
msg: "AAB not found — run build-android-bundle first"
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
- 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
@@ -238,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk:
desc: Build and deploy Android APK via Dagger
@@ -252,7 +261,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website:
desc: Build and publish website via Dagger
@@ -262,7 +271,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -342,7 +351,7 @@ tasks:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
@@ -418,7 +427,7 @@ tasks:
echo "Uploaded $TARBALL and updated latest.json"
deploy-bugreport:
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
desc: Build and deploy the Go bugreport server to the webserver
preconditions:
- sh: test -n "$SSH_USER"
msg: "SSH_USER is not set"
@@ -427,11 +436,14 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
ssh "root@$SSH_HOST" "systemctl restart bugreport"
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
build-windows-release:
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
@@ -529,10 +541,18 @@ tasks:
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-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-local:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
dotenv: [".env"]
method: timestamp
sources:
- lib/**/*.dart
@@ -541,14 +561,7 @@ tasks:
generates:
- build/app/outputs/bundle/release/app-release.aab
cmds:
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
cmds:
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
- 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
@@ -678,9 +691,8 @@ tasks:
${SSH_USER}@${SSH_HOST}:public_html/
check-fast:
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
cmds:
- dagger call --progress=plain -q -m ci --source=. check-fast
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)
@@ -730,11 +742,6 @@ tasks:
cmds:
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
chaos-monkey-backend:
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test]
+16 -13
View File
@@ -22,17 +22,15 @@ android {
}
}
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
if (ksPath != null) {
signingConfigs {
create("release") {
keyAlias = "upload"
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
storePassword = pass
keyPassword = pass
storeFile = file(ksPath)
}
signingConfigs {
create("release") {
// Hardcoded alias matching t.sh
keyAlias = "upload"
// Use the same password for both key and keystore
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
storePassword = pass
keyPassword = pass
storeFile = file("upload-keystore.jks")
}
}
@@ -48,9 +46,14 @@ android {
buildTypes {
release {
if (ksPath != null) {
signingConfig = signingConfigs.getByName("release")
// Use the signing config defined above for release builds.
// If the keystore file exists (e.g. in CI or manually placed), sign it.
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
isMinifyEnabled = false
isShrinkResources = false
ndk {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.2.1" apply false
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
+1 -74
View File
@@ -440,68 +440,6 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
Stdout(ctx)
}
// FormatWrite formats Dart files and exports the modified /src directory.
func (m *Ci) FormatWrite() *dagger.Directory {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "format", "lib", "test"}).
Directory("/src")
}
// Analyze runs static analysis with dart analyze --fatal-infos.
func (m *Ci) Analyze(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
Stdout(ctx)
}
// Codegen runs build_runner and exports the modified /src directory.
func (m *Ci) Codegen() *dagger.Directory {
return m.codegenBase().Directory("/src")
}
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
func (m *Ci) AnalyzeFix() *dagger.Directory {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "fix", "--apply"}).
Directory("/src")
}
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
var eg errgroup.Group
eg.Go(func() error {
_, err := m.CheckHygiene(ctx)
return err
})
eg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
eg.Go(func() error {
_, err := m.Format(ctx)
return err
})
eg.Go(func() error {
_, err := m.Analyze(ctx)
return err
})
eg.Go(func() error {
_, err := m.CheckGenerated(ctx)
return err
})
eg.Go(func() error {
_, err := m.Coverage(ctx)
return err
})
if err := eg.Wait(); err != nil {
return "", err
}
return "All fast checks passed!", nil
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It snapshots the committed source (including any stale generated files) before
// running build_runner, so git diff detects real staleness instead of always
@@ -565,16 +503,6 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
Stdout(ctx)
}
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
func (m *Ci) ChaosMonkeyBackend(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/chaos_monkey_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)
@@ -759,8 +687,7 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
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.
-1
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load .env into environment
+1 -22
View File
@@ -48,28 +48,11 @@
chmod +x $out/bin/fgj
'';
};
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
dagger021 = pkgs.stdenv.mkDerivation {
pname = "dagger";
version = "0.21.4";
src = pkgs.fetchurl {
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
};
sourceRoot = ".";
installPhase = ''
mkdir -p $out/bin
cp dagger $out/bin/dagger
chmod +x $out/bin/dagger
'';
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Dagger CLI
dagger021
dagger.packages.${system}.dagger
# Go compiler — for Dagger development
go
@@ -117,16 +100,12 @@
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
librsvg # rsvg-convert — SVG→PNG for generate-icons task
]);
shellHook = ''
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
export IN_NIX_SHELL=1
# Point Dagger client at the running engine socket
export DAGGER_HOST=unix:///run/dagger/engine.sock
# Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true
-3
View File
@@ -1,3 +0,0 @@
module codeberg.org/guettli/sharedinbox
go 1.22
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 40;
const int dbSchemaVersion = 38;
-16
View File
@@ -192,22 +192,6 @@ class EmailThread {
required this.accountId,
required this.mailboxPath,
});
/// Wraps a single [Email] as a one-message thread for uniform rendering.
factory EmailThread.fromEmail(Email e) => EmailThread(
threadId: e.threadId ?? e.id,
subject: e.subject,
participants: e.from,
latestDate: e.sentAt ?? e.receivedAt,
messageCount: 1,
hasUnread: !e.isSeen,
isFlagged: e.isFlagged,
latestEmailId: e.id,
preview: e.preview,
emailIds: [e.id],
accountId: e.accountId,
mailboxPath: e.mailboxPath,
);
}
class EmailAddress {
-17
View File
@@ -1,17 +0,0 @@
class EmailNote {
final String id; // UUID (X-SharedInbox-Note-Id)
final String accountId;
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
final String noteText;
final String serverId; // IMAP UID (as string) or JMAP email ID
final DateTime createdAt;
const EmailNote({
required this.id,
required this.accountId,
required this.messageId,
required this.noteText,
required this.serverId,
required this.createdAt,
});
}
@@ -20,8 +20,4 @@ abstract class MailboxRepository {
String name,
String role,
);
/// Creates a new mailbox named [name] for [accountId] without a special role.
/// Returns the newly created [Mailbox].
Future<Mailbox> createMailbox(String accountId, String name);
}
@@ -1,15 +0,0 @@
import 'package:sharedinbox/core/models/note.dart';
abstract class NoteRepository {
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
/// Fetches notes from the server into the local cache.
Future<void> syncNotes(String accountId, String messageId);
/// Creates a new note on the server and caches it locally.
Future<void> addNote(String accountId, String messageId, String text);
/// Deletes a note from the server and removes it from the local cache.
Future<void> deleteNote(String noteId);
}
-58
View File
@@ -318,37 +318,6 @@ class ImageTrustedSenders extends Table {
Set<Column> get primaryKey => {senderEmail};
}
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
// Added in schema v39.
@DataClassName('EmailNoteRow')
class EmailNotes extends Table {
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
TextColumn get id => text()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
TextColumn get messageId => text()();
TextColumn get noteText => text()();
// IMAP UID (as string) or JMAP email ID of the note message on the server.
TextColumn get serverId => text()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// Records the first time the user ran each app version (identified by GIT_HASH).
/// Added in schema v40.
@DataClassName('InstalledVersionRow')
class InstalledVersions extends Table {
TextColumn get gitHash => text()();
DateTimeColumn get installedAt => dateTime()();
@override
Set<Column> get primaryKey => {gitHash};
}
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
@@ -394,8 +363,6 @@ class UserPreferences extends Table {
ShareKeys,
UserPreferences,
ImageTrustedSenders,
EmailNotes,
InstalledVersions,
],
)
class AppDatabase extends _$AppDatabase {
@@ -672,33 +639,8 @@ class AppDatabase extends _$AppDatabase {
userPreferences.bodyCacheLimitMb,
);
}
if (from < 39) {
await m.createTable(emailNotes);
}
if (from < 40) {
await m.createTable(installedVersions);
}
},
);
/// Inserts a row for [gitHash] the first time that version is seen.
/// Subsequent calls for the same hash are silently ignored so the original
/// install timestamp is preserved.
Future<void> recordInstalledVersionIfNew(String gitHash) async {
if (gitHash.isEmpty) return;
await into(installedVersions).insert(
InstalledVersionsCompanion.insert(
gitHash: gitHash,
installedAt: DateTime.now(),
),
mode: InsertMode.insertOrIgnore,
);
}
Future<Map<String, DateTime>> loadInstalledVersions() async {
final rows = await select(installedVersions).get();
return {for (final r in rows) r.gitHash: r.installedAt};
}
}
// Resolved once in main() via initDatabasePath() before runApp().
@@ -343,23 +343,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
}
}
@override
Future<model.Mailbox> createMailbox(String accountId, String name) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, null);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, null);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String? role,
String role,
) async {
final client = await _imapConnect(
account,
@@ -392,7 +380,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
account_model.Account account,
String password,
String name,
String? role,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
@@ -410,10 +398,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {
'name': name,
if (role != null) 'role': role,
},
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
@@ -1,570 +0,0 @@
import 'dart:math' as math;
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/jmap_client.dart';
const _notesFolder = 'Notes';
const _headerNoteFor = 'X-SharedInbox-Note-For';
const _headerNoteId = 'X-SharedInbox-Note-Id';
class NoteRepositoryImpl implements NoteRepository {
NoteRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn imapConnect = connectImap,
http.Client? httpClient,
}) : _imapConnect = imapConnect,
_httpClient = httpClient ?? http.Client();
final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn _imapConnect;
final http.Client _httpClient;
String _effectiveUsername(account_model.Account account) =>
account.username.isNotEmpty ? account.username : account.email;
// ── Observe (local cache) ─────────────────────────────────────────────────
@override
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
return (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(accountId) & t.messageId.equals(messageId),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
// ── Sync (server → local cache) ──────────────────────────────────────────
@override
Future<void> syncNotes(String accountId, String messageId) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
await _syncNotesImap(account, password, messageId);
case account_model.AccountType.jmap:
await _syncNotesJmap(account, password, messageId);
}
}
Future<void> _syncNotesImap(
account_model.Account account,
String password,
String messageId,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
} catch (_) {
// Notes folder doesn't exist — nothing to sync.
return;
}
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
final searchResult = await client.uidSearchMessages(
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
);
final uids = searchResult.matchingSequence?.toList() ?? [];
if (uids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
final fetchedIds = <String>{};
for (final msg in fetch.messages) {
final uid = msg.uid;
if (uid == null) continue;
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
if (noteId == null || noteId.isEmpty) continue;
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: msg.decodeTextPlainPart() ?? '',
serverId: uid.toString(),
createdAt: msg.decodeDate() ?? DateTime.now(),
),
);
}
// Remove stale local notes (deleted on the server).
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
} finally {
await client.logout();
}
}
Future<void> _syncNotesJmap(
account_model.Account account,
String password,
String messageId,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findNotesMailboxJmap(jmap);
if (mailboxId == null) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final queryResp = await jmap.call([
[
'Email/query',
{
'accountId': jmap.accountId,
'filter': {'inMailbox': mailboxId},
},
'0',
],
]);
final ids = List<String>.from(
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
);
if (ids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final getResp = await jmap.call([
[
'Email/get',
{
'accountId': jmap.accountId,
'ids': ids,
'properties': [
'id',
'receivedAt',
'textBody',
'bodyValues',
'header:$_headerNoteFor:asText',
'header:$_headerNoteId:asText',
],
'fetchTextBodyValues': true,
},
'0',
],
]);
final list =
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
final fetchedIds = <String>{};
for (final e in list) {
final m = e as Map<String, dynamic>;
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
if (noteFor != messageId) continue;
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
if (noteId == null || noteId.isEmpty) continue;
final jmapEmailId = m['id'] as String;
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
var noteText = '';
if (textBodyParts.isNotEmpty) {
final partId =
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
if (partId != null) {
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
as String? ??
'';
}
}
final createdAt =
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: noteText,
serverId: jmapEmailId,
createdAt: createdAt,
),
);
}
// Remove stale local notes.
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) & t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
}
// ── Add ───────────────────────────────────────────────────────────────────
@override
Future<void> addNote(
String accountId,
String messageId,
String text,
) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
final noteId = _generateId();
switch (account.type) {
case account_model.AccountType.imap:
await _addNoteImap(account, password, messageId, noteId, text);
case account_model.AccountType.jmap:
await _addNoteJmap(account, password, messageId, noteId, text);
}
}
Future<void> _addNoteImap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.createMailbox(_notesFolder);
} catch (_) {
// Already exists.
}
final builder = imap.MessageBuilder()
..subject = 'Note'
..text = text;
builder.addHeader(_headerNoteFor, messageId);
builder.addHeader(_headerNoteId, noteId);
final mime = builder.buildMimeMessage();
final appendResult = await client.appendMessage(
mime,
targetMailboxPath: _notesFolder,
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final serverId = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: serverId,
createdAt: DateTime.now(),
),
);
} finally {
await client.logout();
}
}
Future<void> _addNoteJmap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
const bodyPartId = '1';
final setResp = await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'create': {
'new-note': {
'mailboxIds': {mailboxId: true},
'subject': 'Note',
'keywords': {r'$seen': true},
'headers': [
{'name': _headerNoteFor, 'value': ' $messageId'},
{'name': _headerNoteId, 'value': ' $noteId'},
],
'bodyValues': {
bodyPartId: {
'value': text,
'isEncodingProblem': false,
'isTruncated': false,
},
},
'textBody': [
{'partId': bodyPartId, 'type': 'text/plain'},
],
},
},
},
'0',
],
]);
final result = _responseArgs(setResp, 0, 'Email/set');
final created = result['created'] as Map<String, dynamic>?;
final newEmail = created?['new-note'] as Map<String, dynamic>?;
final jmapEmailId = newEmail?['id'] as String? ?? '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: jmapEmailId,
createdAt: DateTime.now(),
),
);
}
// ── Delete ────────────────────────────────────────────────────────────────
@override
Future<void> deleteNote(String noteId) async {
final noteRow = await (_db.select(_db.emailNotes)
..where((t) => t.id.equals(noteId)))
.getSingleOrNull();
if (noteRow == null) return;
final account = await _accounts.getAccount(noteRow.accountId);
if (account == null) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
.go();
return;
}
final password = await _accounts.getPassword(account.id);
switch (account.type) {
case account_model.AccountType.imap:
await _deleteNoteImap(account, password, noteRow);
case account_model.AccountType.jmap:
await _deleteNoteJmap(account, password, noteRow);
}
}
Future<void> _deleteNoteImap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
final uid = int.tryParse(noteRow.serverId);
if (uid != null) {
final seq = imap.MessageSequence.fromId(uid, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
}
} catch (_) {
// Notes folder gone or message already deleted — clean up locally.
}
} finally {
await client.logout();
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
Future<void> _deleteNoteJmap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
if (noteRow.serverId.isNotEmpty) {
await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'destroy': [noteRow.serverId],
},
'0',
],
]);
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
// ── JMAP helpers ──────────────────────────────────────────────────────────
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
final resp = await jmap.call([
[
'Mailbox/get',
{'accountId': jmap.accountId, 'ids': null},
'0',
],
]);
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
for (final m in list) {
final map = m as Map<String, dynamic>;
if (map['name'] == _notesFolder) return map['id'] as String?;
}
return null;
}
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
final existing = await _findNotesMailboxJmap(jmap);
if (existing != null) return existing;
final resp = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-notes': {'name': _notesFolder},
},
},
'0',
],
]);
final result = _responseArgs(resp, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
return newMailbox?['id'] as String? ?? _notesFolder;
}
Map<String, dynamic> _responseArgs(
List<dynamic> responses,
int index,
String expectedMethod,
) {
final triple = responses[index] as List<dynamic>;
final method = triple[0] as String;
if (method == 'error') {
final err = triple[1] as Map<String, dynamic>;
throw JmapException('$expectedMethod error: ${err['type']}');
}
return triple[1] as Map<String, dynamic>;
}
EmailNote _toModel(EmailNoteRow row) => EmailNote(
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
noteText: row.noteText,
serverId: row.serverId,
createdAt: row.createdAt,
);
// Generates a random UUID v4.
static String _generateId() {
final rng = math.Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
'-${hex.substring(20)}';
}
}
-22
View File
@@ -4,14 +4,12 @@ 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/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
@@ -34,7 +32,6 @@ 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/note_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';
@@ -285,22 +282,3 @@ final trustedImageSendersProvider =
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
return NoteRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
});
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
return ref.watch(dbProvider).loadInstalledVersions();
});
/// Stream of notes for a specific email, identified by (accountId, messageId).
final notesProvider =
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
(ref, params) =>
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
);
-7
View File
@@ -86,8 +86,6 @@ class SharedInboxApp extends ConsumerStatefulWidget {
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
}
const _kGitHash = String.fromEnvironment('GIT_HASH');
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@override
void initState() {
@@ -95,11 +93,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
// Start background IMAP sync once — runs for the lifetime of the app.
ref.read(syncManagerProvider).start();
ref.read(reliabilityRunnerProvider).start();
if (_kGitHash.isNotEmpty) {
unawaited(
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
);
}
}
@override
-17
View File
@@ -1,7 +1,6 @@
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
@@ -22,8 +21,6 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
@@ -57,14 +54,6 @@ final router = GoRouter(
GoRoute(
path: 'undo-log',
builder: (ctx, state) => const UndoLogScreen(),
routes: [
GoRoute(
path: ':actionId',
builder: (ctx, state) => UndoLogDetailScreen(
action: state.extra as UndoAction,
),
),
],
),
GoRoute(
path: 'changelog',
@@ -78,12 +67,6 @@ final router = GoRouter(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: 'trusted-senders',
builder: (ctx, state) => TrustedImageSendersScreen(
highlightedSender: state.extra as String?,
),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
+7 -67
View File
@@ -2,79 +2,21 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
class ChangeLogScreen extends ConsumerWidget {
class ChangeLogScreen extends StatelessWidget {
const ChangeLogScreen({super.key});
static const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
static String _formatInstallDate(DateTime dt) {
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
final month = _months[dt.month - 1];
return '$h:$m, ${dt.day} $month ${dt.year}';
}
// Changelog lines have the form:
// * 2026-06-05 [abc1234](https://...): subject
// This pattern captures the short hash inside the markdown link.
static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\(');
static String _injectInstallMarkers(
String changelog,
Map<String, DateTime> versions,
) {
if (versions.isEmpty) return changelog;
final lines = changelog.split('\n');
final buf = StringBuffer();
for (final line in lines) {
final match = _hashPattern.firstMatch(line);
if (match != null) {
final lineHash = match.group(1)!;
for (final entry in versions.entries) {
final stored = entry.key;
final matches = stored == lineHash ||
stored.startsWith(lineHash) ||
lineHash.startsWith(stored);
if (!matches) continue;
buf.write(
'\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n',
);
break;
}
}
buf.writeln(line);
}
return buf.toString();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final installedVersions = ref.watch(installedVersionsProvider);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ChangeLog')),
body: FutureBuilder<String>(
future:
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
future: DefaultAssetBundle.of(
context,
).loadString('assets/changelog.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting ||
installedVersions.isLoading) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
@@ -83,10 +25,8 @@ class ChangeLogScreen extends ConsumerWidget {
);
}
final content = snapshot.data ?? 'No changelog entries found.';
final versions = installedVersions.value ?? {};
final annotated = _injectInstallMarkers(content, versions);
return Markdown(
data: annotated,
data: content,
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
+142 -171
View File
@@ -3,12 +3,20 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@@ -22,31 +30,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
List<EmailThread> _currentThreads = [];
bool get _selecting => _selectedThreadIds.isNotEmpty;
void _toggleThreadSelection(EmailThread thread) {
setState(() {
if (_selectedThreadIds.contains(thread.threadId)) {
_selectedThreadIds.remove(thread.threadId);
} else {
_selectedThreadIds.add(thread.threadId);
}
});
}
void _clearSelection() => setState(() => _selectedThreadIds.clear());
void _selectAll() {
setState(
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
);
}
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
@@ -75,38 +58,18 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _selecting ? null : _buildDrawer(context, accounts),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
drawer: _buildDrawer(context, accounts),
body: _buildBody(accountNames, showAccount),
floatingActionButton: _selecting
? null
: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
if (_selecting) {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text('${_selectedThreadIds.length} selected'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: 'Select all',
onPressed: _selectAll,
),
],
);
}
return AppBar(
title: const Text('Combined Inbox'),
actions: [
@@ -128,26 +91,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: _batchArchive,
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: _batchDelete,
),
],
),
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
@@ -233,7 +176,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
_currentThreads = threads;
if (threads.isEmpty) {
return ListView(
children: const [
@@ -265,33 +207,119 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
child: const Text('Load more'),
);
}
final t = threads[i];
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
showAccount: showAccount,
accountName: accountNames[t.accountId],
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
},
);
}
Widget _buildThreadTile(
BuildContext ctx,
EmailThread t,
Map<String, String> accountNames,
bool showAccount,
) {
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
if (showAccount)
Text(
accountNames[t.accountId] ?? t.accountId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
child: tile,
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
@@ -342,81 +370,24 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() async {
final repo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
// Group selected threads by accountId so we look up each account's archive once.
final byAccount = <String, List<EmailThread>>{};
for (final t in _currentThreads) {
if (!_selectedThreadIds.contains(t.threadId)) continue;
(byAccount[t.accountId] ??= []).add(t);
}
_clearSelection();
for (final entry in byAccount.entries) {
final accountId = entry.key;
final threads = entry.value;
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
if (!mounted || archive == null) continue;
for (final t in threads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
}
}
Future<void> _batchDelete() async {
final repo = ref.read(emailRepositoryProvider);
final selectedThreads = _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.toList();
_clearSelection();
for (final t in selectedThreads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+4 -180
View File
@@ -12,7 +12,6 @@ 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/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
@@ -38,7 +37,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
bool _notesSynced = false;
@override
Widget build(BuildContext context) {
@@ -52,15 +50,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
if (!_notesSynced && email?.messageId != null) {
_notesSynced = true;
unawaited(
ref.read(noteRepositoryProvider).syncNotes(
email!.accountId,
email.messageId!,
),
);
}
},
);
@@ -240,14 +229,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
context.push('/accounts/preferences'),
);
}
},
@@ -268,7 +254,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body.textBody ?? '',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (header?.messageId != null) _buildNotesSection(ctx, header!),
if (body.attachments.isNotEmpty) ...[
const Divider(),
Padding(
@@ -352,114 +337,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}
}
Widget _buildNotesSection(BuildContext ctx, Email header) {
final messageId = header.messageId!;
final notes = ref.watch(notesProvider((header.accountId, messageId)));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'Notes',
style: Theme.of(ctx).textTheme.titleSmall,
),
),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('Add'),
onPressed: () => unawaited(_addNoteDialog(ctx, header)),
),
],
),
notes.when(
loading: () => const SizedBox.shrink(),
error: (e, _) => Text('Error loading notes: $e'),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text(
'No notes yet.',
style: TextStyle(color: Colors.grey),
),
);
}
return Column(
children: [
for (final note in list) _buildNoteRow(ctx, note),
],
);
},
),
],
);
}
Widget _buildNoteRow(BuildContext ctx, EmailNote note) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(note.noteText),
subtitle: Text(
DateFormat('MMM d, HH:mm').format(note.createdAt),
style: Theme.of(ctx).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Delete note',
onPressed: () {
unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id));
},
),
);
}
Future<void> _addNoteDialog(BuildContext context, Email header) async {
final messageId = header.messageId;
if (messageId == null) return;
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Add note'),
content: TextField(
controller: ctrl,
autofocus: true,
maxLines: 4,
decoration: const InputDecoration(hintText: 'Type a note…'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Save'),
),
],
),
);
final text = ctrl.text.trim();
ctrl.dispose();
if (confirmed != true || text.isEmpty) return;
if (!context.mounted) return;
await ref.read(noteRepositoryProvider).addNote(
header.accountId,
messageId,
text,
);
}
Widget _buildHeader(BuildContext ctx, Email email) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -683,42 +560,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String?> _promptNewFolderName(BuildContext context) async {
final controller = TextEditingController();
try {
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Create new folder'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'Folder name'),
textCapitalization: TextCapitalization.words,
onSubmitted: (value) {
if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim());
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.pop(ctx, name);
},
child: const Text('Create'),
),
],
),
);
} finally {
controller.dispose();
}
}
Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
@@ -732,8 +573,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return;
const createNewSentinel = '__create_new__';
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
@@ -751,28 +590,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('Create new folder…'),
onTap: () => Navigator.pop(ctx, createNewSentinel),
),
],
),
);
if (chosen == null || !context.mounted) return;
String destination = chosen;
if (chosen == createNewSentinel) {
final name = await _promptNewFolderName(context);
if (name == null || !context.mounted) return;
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
destination = mailbox.path;
}
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, destination);
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -782,7 +606,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destination,
destinationMailboxPath: chosen,
),
),
);
+188 -101
View File
@@ -12,10 +12,19 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.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';
import 'package:sharedinbox/ui/widgets/thread_tile.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({
@@ -50,15 +59,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
// Incremented on every search start; stale completions are ignored when the
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
// The query whose results are currently settled in _searchResults.
// Used to skip redundant re-runs when the user presses Enter on an
// already-settled search (issue #473).
String? _lastSettledQuery;
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -70,7 +70,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() {
_searchResults = null;
_searchLoading = false;
_lastSettledQuery = null;
});
}
});
@@ -127,35 +126,18 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
Future<void> _runSearch(String query) async {
final q = query.trim();
if (q.isEmpty) {
setState(() {
_searchResults = null;
_lastSettledQuery = null;
});
if (query.trim().isEmpty) {
setState(() => _searchResults = null);
return;
}
// Skip if results are already settled for this exact query — prevents the
// Enter key from re-triggering an IMAP search that already completed.
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
return;
}
final generation = ++_searchGeneration;
setState(() => _searchLoading = true);
try {
final results = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, q);
if (mounted && generation == _searchGeneration) {
setState(() {
_searchResults = results;
_lastSettledQuery = q;
});
}
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
if (mounted) setState(() => _searchResults = results);
} finally {
if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
if (mounted) setState(() => _searchLoading = false);
}
}
@@ -706,93 +688,177 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
final t = threads[i];
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: _selecting
? Checkbox(
value: isSelected,
onChanged: (_) => _toggleThreadSelection(t),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
// For swipe actions on threads, operate on the latest email only
// (single-email threads) or the whole thread.
return Dismissible(
key: ValueKey(t.threadId),
direction:
_selecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
} else {
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
}
},
child: tile,
);
},
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
// Used for search results, which are individual emails.
Widget _buildEmailList(List<Email> emails) {
return ListView.builder(
itemCount: emails.length,
itemBuilder: (ctx, i) {
final e = emails[i];
final t = EmailThread.fromEmail(e);
final isSelected = _selectedSearchIds.contains(e.id);
return ThreadTile(
thread: t,
return EmailTile(
email: e,
selected: isSelected,
leading: SizedBox(
width: 40,
@@ -811,4 +877,25 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
},
);
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+4 -4
View File
@@ -8,7 +8,7 @@ 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/thread_tile.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref,
@@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'),
for (final e in r.emails)
ThreadTile(
thread: EmailThread.fromEmail(e),
locationLabel: '${e.accountId}${e.mailboxPath}',
EmailTile(
email: e,
showLocation: true,
onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}'
+2 -5
View File
@@ -217,14 +217,11 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
context.push('/accounts/preferences'),
);
}
},
@@ -1,63 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/di.dart';
class TrustedImageSendersScreen extends ConsumerWidget {
const TrustedImageSendersScreen({super.key, this.highlightedSender});
final String? highlightedSender;
@override
Widget build(BuildContext context, WidgetRef ref) {
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Allowed addresses for images')),
body: trustedSendersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading trusted senders')),
data: (senders) {
if (senders.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text(
'No addresses added yet. '
'Tap "Load remote images" in an email to add the sender.',
),
);
}
return ListView.builder(
itemCount: senders.length,
itemBuilder: (context, index) {
final sender = senders[index];
final isHighlighted = sender == highlightedSender;
return ListTile(
title: Text(
sender,
style: isHighlighted
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
);
},
);
},
),
);
}
}
-139
View File
@@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
class UndoLogDetailScreen extends ConsumerWidget {
const UndoLogDetailScreen({super.key, required this.action});
final UndoAction action;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Undo Log Detail'),
actions: [
TextButton(
onPressed: () async {
await ref
.read(undoServiceProvider.notifier)
.undo(actionId: action.id);
if (context.mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Action undone.'),
),
);
}
},
child: const Text('Undo'),
),
],
),
body: ListView(
children: [
_SectionHeader(text: 'Transaction', theme: theme),
ListTile(
leading: const Icon(Icons.account_circle),
title: const Text('Account'),
subtitle: Text(action.accountId),
),
ListTile(
leading: Icon(
action.type == UndoType.delete
? Icons.delete_outline
: (action.type == UndoType.snooze
? Icons.access_time
: Icons.move_to_inbox),
color: action.type == UndoType.delete
? Colors.redAccent
: (action.type == UndoType.snooze
? Colors.orangeAccent
: Colors.blueAccent),
),
title: const Text('Action'),
subtitle: Text(action.type.name.toUpperCase()),
),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('Timestamp'),
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
),
_SectionHeader(text: 'Folders', theme: theme),
ListTile(
leading: const Icon(Icons.folder_open),
title: const Text('Source'),
subtitle: Text(action.sourceMailboxPath),
),
if (action.type == UndoType.move &&
action.destinationMailboxPath != null)
ListTile(
leading: const Icon(Icons.drive_file_move),
title: const Text('Destination'),
subtitle: Text(action.destinationMailboxPath!),
),
_SectionHeader(
text: 'Emails (${action.emailIds.length})',
theme: theme,
),
if (action.originalEmails.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'${action.emailIds.length} email(s) — details not available',
style: theme.textTheme.bodySmall,
),
),
...action.originalEmails.map((email) => _EmailTile(email: email)),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text, required this.theme});
final String text;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
);
}
}
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email});
final Email email;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(Unknown Sender)';
return ListTile(
leading: const Icon(Icons.email_outlined),
title: Text(email.subject ?? '(No Subject)'),
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
}
-5
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
@@ -56,10 +55,6 @@ class _UndoActionTile extends ConsumerWidget {
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
return ListTile(
onTap: () => context.go(
'/accounts/undo-log/${action.id}',
extra: action,
),
leading: Icon(
action.type == UndoType.delete
? Icons.delete_outline
+32 -9
View File
@@ -2,7 +2,6 @@ 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/user_preferences.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
@@ -15,7 +14,6 @@ class UserPreferencesScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
final trustedCount = trustedSendersAsync.value?.length ?? 0;
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
@@ -215,16 +213,41 @@ class UserPreferencesScreen extends ConsumerWidget {
const Divider(),
ListTile(
title: Text(
'Allowed addresses for images',
'Trusted image senders',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: Text(
trustedCount == 0
? 'No addresses added yet.'
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
subtitle: const Text(
'Remote images are loaded automatically for these senders.',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/accounts/trusted-senders'),
),
...trustedSendersAsync.when(
loading: () => const [],
error: (_, __) => const [],
data: (senders) => senders.isEmpty
? [
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No trusted senders yet.'),
),
]
: [
for (final sender in senders)
ListTile(
title: Text(sender),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
),
],
),
],
),
-171
View File
@@ -1,171 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
/// A swipeable list tile for an [EmailThread].
///
/// Handles the [Dismissible] wrapper (archive left, delete right) and
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
/// line with the account name — used in the combined-inbox view.
class EmailThreadTile extends StatelessWidget {
const EmailThreadTile({
super.key,
required this.thread,
required this.isSelected,
required this.isSelecting,
required this.onTap,
required this.onLongPress,
required this.onDismissed,
this.showAccount = false,
this.accountName,
});
final EmailThread thread;
final bool isSelected;
final bool isSelecting;
final VoidCallback onTap;
final VoidCallback onLongPress;
final Future<void> Function(DismissDirection) onDismissed;
/// When true, renders an extra subtitle line with [accountName].
final bool showAccount;
final String? accountName;
@override
Widget build(BuildContext context) {
final t = thread;
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: isSelecting
? Checkbox(
value: isSelected,
onChanged: (_) => onTap(),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (showAccount && accountName != null)
Text(
accountName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
onTap: onTap,
onLongPress: onLongPress,
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
direction:
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: onDismissed,
child: tile,
);
}
static Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
-121
View File
@@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
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);
/// A list tile for an [EmailThread].
///
/// Used in inbox lists, combined inbox, and search result lists.
/// Pass a custom [leading] widget to support selection-mode checkboxes.
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
/// "accountId • mailboxPath") — useful in cross-mailbox views.
class ThreadTile extends StatelessWidget {
const ThreadTile({
super.key,
required this.thread,
required this.onTap,
this.leading,
this.selected = false,
this.onLongPress,
this.locationLabel,
});
final EmailThread thread;
final VoidCallback onTap;
final Widget? leading;
final bool selected;
final VoidCallback? onLongPress;
/// When non-null, appended as an extra subtitle line in primary colour.
final String? locationLabel;
@override
Widget build(BuildContext context) {
final senderNames = thread.participants.isEmpty
? '(unknown)'
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
return ListTile(
leading: leading ??
Icon(
thread.hasUnread ? Icons.mail : Icons.mail_outline,
color:
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (thread.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${thread.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
thread.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (thread.preview != null && thread.preview!.isNotEmpty)
Text(
thread.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (locationLabel != null)
Text(
locationLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (thread.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(thread.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
selected: selected,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
-4
View File
@@ -102,7 +102,3 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
-2
View File
@@ -31,8 +31,6 @@ static void my_application_activate(GApplication* application) {
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
// Show AFTER adding FlView so GTK's first layout pass allocates the full
// window content area (1280×800) to FlView, not the default 1×1.
gtk_widget_show_all(GTK_WIDGET(window));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

-50
View File
@@ -1,50 +0,0 @@
# Plan Log
## 2026-05-10
- Improved Undo Log (Issue #7): Added support for undoing any action from history.
- Refactored `UndoService.undo()` to support targeted rollbacks by action ID.
- Removed "latest only" restriction from `UndoLogScreen`.
- Successfully deployed release APK to distribution server via `task deploy-android`.
- Verified system integrity with unit, widget, and E2E integration tests.
## 2026-05-10
- Implemented global Undo Log with persistent history.
- Refactored `UndoService` to store a list of recent actions instead of just the latest one.
- Added `UndoLogScreen` to view and interact with undo history.
- Added "History" icon to account list for better discoverability.
- Updated `.gitignore` to better handle Dart/Flutter and Android tool artifacts.
- Verified all changes with fast check suite (analyze + unit + widget tests).
## 2026-05-09
- Fixed Crash Page (Issue 3): Added Codeberg reporting button.
- Fixed Show Mail Headers (Issue 1): Added raw header storage and UI display.
- Fixed Exception on Undo of delete (Issue 2): Added serialization to EmailAddress.
- Updated Taskfile with Nix experimental features check.
- Pushed all changes to branch `fix-issues`.
## 2026-05-09
- Fixed Undo feature for IMAP accounts.
- Identified that IMAP moveEmail hard-deletes local rows, making Undo impossible without data.
- Added `originalEmails` to `UndoAction` and `restoreEmails` to `EmailRepository`.
- Updated UI to fetch email data before move/delete to support restoration.
- Fixed `UndoService` to restore rows and be more robust with pending change cancellation.
- Verified with `test/unit/undo_reproduction_test.dart` and updated unit tests.
- Successfully deployed to Android.
## 2026-05-09
- Implemented Network Resilience (Task 1/4 from next.md).
- Added exponential backoff logic (5s to 15m) to IMAP and JMAP sync loops.
- Added permanent error detection (auth/credentials) to stop sync loops gracefully.
- Improved "Pull to Refresh" in email list to trigger full account sync and bypass backoff.
- Verified with integration tests.
- Started work on Sync Reliability (Task 1/5 from next.md).
- Added `verifySyncReliability` to `EmailRepository` interface and models.
- Implemented `verifySyncReliability` in `EmailRepositoryImpl` for IMAP and JMAP.
- Added `SyncHealth` table to database (Schema v19).
- Created `ReliabilityRunner` for periodic verification.
- Integrated sync health indicators in `AccountListScreen` UI.
- Added manual "Verify sync health" action.
- Verified with new integration tests in `test/integration/sync_reliability_test.dart`.
- All integration tests (IMAP and JMAP) passing.
- Fixed several compilation and analysis issues.
+66
View File
@@ -0,0 +1,66 @@
# Next
## Introduction
Continue the momentum from the safety hardening and infrastructure work.
The focus is on making the app ready for real-world use with robust error
handling and performance optimizations.
Create several small commits. Every commit should be self contained.
while working create/append to plan.log, so that the user sees what you are working on.
## Tasks
### 0. deploy-android
Make `task deploy-android` work.
### 0.5 Debug duration of deploy-android
Is there a way to make deploy-android faster?
Use `task --verbose` to see what gets done.
Maybe avoid doing things again, when nothing changed.
Taskfile has features to avoid calling things again, when the input has not changed.
### 1. Fix Android E2E Race Condition (aliceTile)
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
The current "double pumpUntil" fix isn't reliable enough.
Investigate if the animation state or the Drift stream propagation is the
culprit.
### 2. Implement Global Crash Screen
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
Implement a `CrashScreen` widget that shows the stack trace and a
"Copy to Clipboard" button for user reporting.
### 3. Database-Backed Threading
Currently, emails are grouped into threads in-memory in the repository.
Refactor to store thread relationships in the local SQLite database.
This is necessary for performance on mailboxes with thousands of messages.
### 4. Implement Undo for Bulk Actions
Add a global "Undo" snackbar after deleting or moving emails.
The system needs to handle the three sync states:
- Queued (easy to undo)
- In-progress (cancel network call)
- Finished (requires a reverse move/un-delete)
### 5. Transition to Real Account Testing
Prepare the integration tests to run against a real test account
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
This verifies the app against real-world network latency and RFC edge cases.
### 6. Coverage Gate Maintenance
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
Add a test to ensure the exclusion list doesn't contain files that no longer
exist ("ghost paths").
-16
View File
@@ -371,14 +371,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -570,14 +562,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
integration_test:
dependency: "direct dev"
description: flutter
-9
View File
@@ -81,15 +81,6 @@ dev_dependencies:
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
flutter_launcher_icons: ^0.14.0
flutter_icons:
android: "ic_launcher"
ios: false
image_path: "icon.png"
linux:
generate: true
image_path: "icon.png"
flutter:
uses-material-design: true
-8
View File
@@ -19,14 +19,6 @@
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^\\.fvmrc$"],
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
"datasourceTemplate": "docker",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"fileMatch": ["^\\.forgejo/Dockerfile$"],
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
trap "rm -f $tmp" EXIT
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
ANDROID_KEYSTORE_PATH="$tmp" \
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"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT=$(git rev-parse --show-toplevel)
FILE="$ROOT/ci/main.go"
# Static images from From("...") literals in ci/main.go
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
FVMRC="$ROOT/.fvmrc"
-7
View File
@@ -23,8 +23,6 @@ const _noCode = {
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/models/note.dart',
'lib/core/repositories/note_repository.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -57,7 +55,6 @@ const _excluded = {
'lib/ui/screens/sieve_scripts_screen.dart',
'lib/ui/screens/sync_log_screen.dart',
'lib/ui/screens/thread_detail_screen.dart',
'lib/ui/screens/undo_log_detail_screen.dart',
'lib/ui/screens/undo_log_screen.dart',
'lib/ui/widgets/folder_drawer.dart',
'lib/ui/widgets/secure_email_webview.dart',
@@ -84,10 +81,6 @@ const _excluded = {
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart',
'lib/ui/widgets/email_thread_tile.dart',
'lib/ui/screens/trusted_image_senders_screen.dart',
'lib/data/repositories/note_repository_impl.dart',
'lib/ui/widgets/thread_tile.dart',
};
void main() {
+1 -5
View File
@@ -34,7 +34,7 @@ _filter_noise() {
_run() {
: > "$OUT" ; : > "$RC_FILE"
{
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
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"
@@ -44,10 +44,6 @@ _run() {
for attempt in 1 2 3; do
_run && break
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
if [ "$RC" -eq 124 ]; then
echo "::warning::[firebase] attempt $attempt/3 timed out after 2400s" >&2
exit 124
fi
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
+2 -27
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
[ "${CI:-}" = "true" ] || [ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set."
@@ -17,25 +16,12 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
# must be masked line-by-line because ::add-mask:: covers one line at a time.
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
while IFS= read -r line; do
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
done <<< "$DAGGER_SSH_KEY"
# Export all CI secrets to the GitHub Actions environment so subsequent steps
# can use them without referencing Forgejo secrets directly.
export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
# Register each non-empty line for log redaction in the Actions runner.
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
while IFS= read -r line; do
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
done <<< "$value"
fi
if [ -n "${GITHUB_ENV:-}" ]; then
# Use heredoc syntax for multiline-safe export.
# Avoid adding a second trailing newline for values that already end with one
@@ -64,27 +50,16 @@ export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
rm -f ~/.ssh/dagger_key
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
# Add remote host to known_hosts
_t0=$SECONDS
timeout 30 ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
fi
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
_t0=$SECONDS
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::SSH tunnel setup took ${_elapsed}s"
fi
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
+3
View File
@@ -0,0 +1,3 @@
module sharedinbox.de/bugreport
go 1.21
+27 -11
View File
@@ -2,6 +2,8 @@ package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -11,6 +13,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
@@ -18,10 +21,12 @@ import (
// BugReport represents the data stored in report.json
type BugReport struct {
Description string `json:"description"`
Email string `json:"email"`
AboutInfo string `json:"about_info"`
EmailData string `json:"email_data,omitempty"`
SyncLog string `json:"sync_log,omitempty"`
Timestamp time.Time `json:"timestamp"`
HashedIP string `json:"hashed_ip"`
}
var (
@@ -70,6 +75,12 @@ func generateUUID() (string, error) {
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
}
func hashIP(ip string) string {
h := sha256.New()
h.Write([]byte(ip))
return hex.EncodeToString(h.Sum(nil))
}
func bugReportHandler(storageDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Enable CORS so the web app (if applicable) can upload
@@ -132,6 +143,20 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
emailData := r.FormValue("email_data")
syncLog := r.FormValue("sync_log")
// Get IP address
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
// Check X-Forwarded-For if behind a proxy
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if len(parts) > 0 {
ip = strings.TrimSpace(parts[0])
}
}
hashedIP := hashIP(ip)
uuidVal, err := generateUUID()
if err != nil {
log.Printf("Failed to generate UUID: %v", err)
@@ -154,10 +179,12 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
// Write report.json
report := BugReport{
Description: description,
Email: email,
AboutInfo: aboutInfo,
EmailData: emailData,
SyncLog: syncLog,
Timestamp: now,
HashedIP: hashedIP,
}
reportJSONPath := filepath.Join(reportDir, "report.json")
@@ -178,17 +205,6 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
return
}
// Write contact email to mail.eml (kept separate from report.json to isolate PII)
if email != "" {
mailEmlPath := filepath.Join(reportDir, "mail.eml")
err = os.WriteFile(mailEmlPath, []byte(email), 0600)
if err != nil {
log.Printf("Failed to write mail.eml: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Save attachments
form := r.MultipartForm
files := form.File["attachments[]"]
-1
View File
@@ -7,7 +7,6 @@
# Run inside nix develop:
# stalwart-dev/integration_android_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
_SCRIPT_START=$(date +%s%3N)
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
-1
View File
@@ -5,7 +5,6 @@
#
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
# Timing helper: prints elapsed seconds since script start with a label.
_SCRIPT_START=$(date +%s%3N)
-1
View File
@@ -2,7 +2,6 @@
# Starts Stalwart in the background on fresh random ports, runs Flutter
# integration tests, then stops it.
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
@@ -169,15 +169,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-221
View File
@@ -1,221 +0,0 @@
// Chaos monkey test — drives the email repository through random operations
// against a live Stalwart instance to surface crashes and data-corruption bugs.
//
// Run via: stalwart-dev/test.sh
//
// Environment variables:
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
import 'dart:io';
import 'dart:math';
import 'package:enough_mail/enough_mail.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' as email_model;
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:test/test.dart';
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
import '../unit/db_test_helper.dart';
String _env(String key, [String fallback = '']) =>
Platform.environment[key] ?? fallback;
Future<ImapClient> _imapConnectPlain(
Account account,
String username,
String password,
) async {
final client =
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
await client.connectToServer(
account.imapHost,
account.imapPort,
isSecure: false,
);
await client.login(username, password);
return client;
}
Future<SmtpClient> _smtpConnectPlain(
Account account,
String username,
String password,
) async {
final atIndex = account.email.lastIndexOf('@');
final domain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
final client = SmtpClient(domain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
isSecure: false,
);
await client.ehlo();
await client.authenticate(username, password);
return client;
}
Future<void> _clearMailbox(
Account account,
String userEmail,
String userPass,
String mailboxPath,
) async {
final client = await _imapConnectPlain(account, userEmail, userPass);
try {
final box = await client.selectMailboxByPath(mailboxPath);
if (box.messagesExists == 0) return;
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return;
final seq = MessageSequence.fromIds(uids, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
} finally {
await client.logout();
}
}
void main() {
late String imapHost;
late int imapPort;
late String smtpHost;
late int smtpPort;
late String userEmail;
late String userPass;
late Account account;
late AppDatabase db;
late EmailRepositoryImpl emails;
setUpAll(configureSqliteForTests);
setUp(() async {
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
userEmail = _env('STALWART_USER_B', 'alice@example.com');
userPass = _env('STALWART_PASS_B', 'secret');
account = Account(
id: 'chaos',
displayName: 'Chaos',
email: userEmail,
imapHost: imapHost,
imapPort: imapPort,
imapSsl: false,
smtpHost: smtpHost,
smtpPort: smtpPort,
);
db = openTestDatabase();
final secureStorage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, secureStorage);
await accounts.addAccount(account, userPass);
emails = EmailRepositoryImpl(
db,
accounts,
imapConnect: _imapConnectPlain,
smtpConnect: _smtpConnectPlain,
);
await _clearMailbox(account, userEmail, userPass, 'INBOX');
});
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
() async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
: int.parse(seedStr);
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
final rng = Random(seed);
stdout.writeln('chaos-monkey: seed=$seed rounds=$rounds');
// Seed INBOX with a few messages so early rounds have something to act on.
for (var i = 0; i < 3; i++) {
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: 'seed-$i',
body: 'Seed email $i.',
),
);
}
await emails.syncEmails(account.id, 'INBOX');
for (var round = 0; round < rounds; round++) {
final action = rng.nextInt(8);
stdout.writeln('chaos-monkey: round=$round action=$action');
switch (action) {
case 0: // sync INBOX
await emails.syncEmails(account.id, 'INBOX');
case 1: // sync Sent
await emails.syncEmails(account.id, 'Sent');
case 2: // send email to self
final subject = 'chaos-$round-${rng.nextInt(9999)}';
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: subject,
body: 'Round $round. Value: ${rng.nextInt(1000000)}.',
),
);
case 3: // mark random email seen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: true);
case 4: // mark random email unseen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: false);
case 5: // toggle flagged on random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, flagged: !e.isFlagged);
case 6: // flush pending changes to server
final flushed =
await emails.flushPendingChanges(account.id, userPass);
stdout.writeln('chaos-monkey: flushed $flushed pending changes');
case 7: // delete random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.deleteEmail(e.id);
}
}
// Final flush and sync to confirm the server is in a consistent state.
final flushed = await emails.flushPendingChanges(account.id, userPass);
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
final result = await emails.syncEmails(account.id, 'INBOX');
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
});
}
-9
View File
@@ -239,15 +239,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
@@ -235,31 +235,6 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
),
)),
) as _i5.Future<_i2.Mailbox>);
@override
_i5.Future<_i2.Mailbox> createMailbox(
String? accountId,
String? name,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
}
/// A class which mocks [EmailRepository].
+2 -16
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 40);
expect(db.schemaVersion, 38);
await db.close();
});
@@ -424,18 +424,12 @@ void main() {
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
// v40: installed_versions table.
await db.customSelect('SELECT count(*) FROM installed_versions').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 40', () async {
test('fresh install creates all tables at schemaVersion 38', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -464,8 +458,6 @@ void main() {
'local_sieve_applied', // v32
'user_preferences', // v34
'image_trusted_senders', // v37
'email_notes', // v39
'installed_versions', // v40
]),
);
@@ -501,12 +493,6 @@ void main() {
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
// v40: installed_versions table.
await db.customSelect('SELECT count(*) FROM installed_versions').get();
await db.close();
});
});
@@ -77,15 +77,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-9
View File
@@ -67,15 +67,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
+8 -59
View File
@@ -1,12 +1,8 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
class _FakeAssetBundle extends CachingAssetBundle {
@@ -23,33 +19,16 @@ class _FakeAssetBundle extends CachingAssetBundle {
}
}
Widget _buildScreen({
required Map<String, String> assets,
Map<String, DateTime> installedVersions = const {},
}) {
return ProviderScope(
overrides: [
dbProvider.overrideWith((ref) {
final db = AppDatabase(NativeDatabase.memory());
ref.onDispose(db.close);
return db;
}),
installedVersionsProvider.overrideWith((ref) async => installedVersions),
],
child: DefaultAssetBundle(
bundle: _FakeAssetBundle(assets),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
}
const _fakeChangelog =
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
void main() {
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
await tester.pumpWidget(
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
DefaultAssetBundle(
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
@@ -62,44 +41,14 @@ void main() {
testWidgets('ChangeLogScreen shows error when asset is missing', (
tester,
) async {
await tester.pumpWidget(_buildScreen(assets: {}));
await tester.pumpAndSettle();
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
testWidgets('ChangeLogScreen injects install marker for a known hash', (
tester,
) async {
const changelog =
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
final installedAt = DateTime(2024, 6, 15, 14, 32);
await tester.pumpWidget(
_buildScreen(
assets: {'assets/changelog.txt': changelog},
installedVersions: {'abc1234': installedAt},
DefaultAssetBundle(
bundle: _FakeAssetBundle({}),
child: const MaterialApp(home: ChangeLogScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Installed: 14:32'), findsOneWidget);
expect(find.textContaining('15 Jun 2024'), findsOneWidget);
expect(find.textContaining('initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen shows no markers when no version recorded', (
tester,
) async {
const changelog =
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
await tester.pumpWidget(
_buildScreen(assets: {'assets/changelog.txt': changelog}),
);
await tester.pumpAndSettle();
expect(find.textContaining('Installed:'), findsNothing);
expect(find.textContaining('initial release'), findsOneWidget);
expect(find.textContaining('Error loading changelog'), findsOneWidget);
});
}
-187
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -432,191 +430,6 @@ void main() {
expect(find.text('Result email'), findsWidgets);
});
testWidgets(
'tapping first of multiple search results opens the first email',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
searchResults: [email1, email2],
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
// Tap the first result.
await tester.tap(find.text('Alpha Match'));
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Alpha Match'),
),
findsOneWidget,
);
// The second email's subject must not appear in the detail view.
expect(
find.descendant(
of: find.byType(EmailDetailScreen),
matching: find.text('Beta Match'),
),
findsNothing,
);
},
);
testWidgets(
'stale search results from a slower concurrent search are discarded',
(tester) async {
// Reproduces: user types quickly, triggering multiple concurrent IMAP
// searches. An older, slower search must not overwrite the results for
// the user's current query (issue #467).
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
// The first search call is held open by a Completer; all subsequent
// calls resolve immediately with freshEmail.
final staleCompleter = Completer<List<Email>>();
var firstCall = true;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) {
if (firstCall) {
firstCall = false;
return staleCompleter.future;
}
return Future.value([freshEmail]);
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Trigger the first (slow) search.
await tester.enterText(find.byType(TextField), 'slow');
await tester.testTextInput.receiveAction(TextInputAction.search);
// Do not pumpAndSettle yet — the slow search is still in flight.
// Trigger the second (fast) search by changing the query.
await tester.enterText(find.byType(TextField), 'fast');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); // fast searches settle immediately
// The fresh results must be shown.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
// Now let the stale search complete.
staleCompleter.complete([staleEmail]);
await tester.pumpAndSettle();
// The stale results must NOT replace the fresh ones.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
},
);
testWidgets(
'pressing Enter on already-settled search does not re-run search (issue #473)',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
var searchCallCount = 0;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
searchCallCount++;
return [email1, email2];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Run the initial search.
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
final countAfterFirstSearch = searchCallCount;
// Re-focus the search bar (simulates user tapping back into the field
// with the keyboard still visible) and press Enter again on the same,
// already-settled query.
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// The search must NOT re-run; call count must not increase.
expect(
searchCallCount,
countAfterFirstSearch,
reason:
'Enter on settled results must not re-run the search (issue #473)',
);
// Results must still be visible — no loading spinner.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Alpha Match'), findsOneWidget);
},
);
testWidgets('deleting all search results pops back to previous screen', (
tester,
) async {
+3 -32
View File
@@ -192,20 +192,6 @@ class FakeMailboxRepository implements MailboxRepository {
_mailboxes.add(mailbox);
return mailbox;
}
@override
Future<Mailbox> createMailbox(String accountId, String name) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
}
class FakeEmailRepository implements EmailRepository {
@@ -216,17 +202,12 @@ class FakeEmailRepository implements EmailRepository {
final List<Email> _searchResults;
/// Optional override: when set, [searchEmails] calls this instead of
/// returning [_searchResults]. Useful for testing race-condition fixes.
final Future<List<Email>> Function(String query)? onSearch;
FakeEmailRepository({
List<Email>? emails,
Email? emailDetail,
EmailBody? emailBody,
List<Email>? searchResults,
String rawRfc822 = '',
this.onSearch,
}) : _emails = emails ?? [],
_emailDetail = emailDetail,
_searchResults = searchResults ?? [],
@@ -279,15 +260,7 @@ class FakeEmailRepository implements EmailRepository {
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
@override
Future<Email?> getEmail(String emailId) async {
for (final e in _searchResults) {
if (e.id == emailId) return e;
}
for (final e in _emails) {
if (e.id == emailId) return e;
}
return _emailDetail;
}
Future<Email?> getEmail(String emailId) async => _emailDetail;
@override
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
@@ -353,10 +326,8 @@ class FakeEmailRepository implements EmailRepository {
String accountId,
String mailboxPath,
String query,
) async {
if (onSearch != null) return onSearch!(query);
return _searchResults;
}
) async =>
_searchResults;
@override
Future<List<Email>> searchEmailsGlobal(
+57
View File
@@ -0,0 +1,57 @@
# Play Store Publishing Roadmap
To publish the Flutter app to the Play Store, you need to transition from a "development" state to a "production-ready" state.
Data Protection blabla page!
## 1. What has been done
* **Application ID:** Changed to `de.sharedinbox.mua` (verified in `build.gradle.kts`, `MainActivity.kt`, and integration tests).
* **Build Logic:** `android/app/build.gradle.kts` now supports:
* **Local builds:** Using `key.properties` (ignored by git).
* **CI builds:** Using environment variables (`ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `ANDROID_KEYSTORE_PASSWORD`).
* **Taskfile:** Added `task build-android-bundle` to generate the `.aab` file.
* **CI Workflow:** Created `.forgejo/workflows/release.yml` which triggers on merge to `main`.
### A. Create the Keystore
Run the helper script I created for you:
```bash
./t.sh
```
Follow the prompts and use a strong password (24-32 chars).
### B. Configure Codeberg Secrets
Go to **Settings > Actions > Secrets** in your Codeberg repo and add:
1. **`ANDROID_KEYSTORE_BASE64`**: The output of `base64 -w 0 android/app/upload-keystore.jks`.
2. **`ANDROID_KEYSTORE_PASSWORD`**: Your keystore password.
3. **`PLAY_STORE_CONFIG_JSON`**: The JSON key from your Google Play Service Account.
### C. First Manual Upload
Google Play requires the **very first upload** to be done manually through the web console:
1. Generate your keystore using `./t.sh`.
2. Run the build locally using temporary environment variables:
```bash
export ANDROID_KEYSTORE_PASSWORD=your_password
nix develop --command task build-android-bundle
```
3. Upload the resulting `.aab` from `build/app/outputs/bundle/release/app-release.aab` to the Play Console (Internal Testing or Production track).
4. This "locks in" your signing key.
## 2. What you need to do next
## 3. Firebase Test Lab
Once you have the Service Account JSON, you can add a task to `Taskfile.yml` to run automated tests on real devices:
```yaml
test-lab:
desc: Run integration tests in Firebase Test Lab
cmds:
- gcloud firebase test android run \
--type instrumentation \
--app build/app/outputs/apk/debug/app-debug.apk \
--test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=virtuall1,version=30
```
**Recommendation:** Complete step **A** (Keystore) and **B** (Secrets) first. Once the first manual upload is done, the CI will take over for all future merges to `main`.