Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e510ea802 | ||
|
|
76f2635700 | ||
|
|
e2bb299300 | ||
|
|
f5abe9132b | ||
|
|
d55b316d4c | ||
|
|
f7fd30da15 | ||
|
|
d92cfac761 | ||
|
|
57b266a82b | ||
|
|
b7a8624c38 | ||
|
|
1e2f124cd0 | ||
|
|
916fc4bc6b | ||
|
|
96332b1262 | ||
|
|
a67b707a41 | ||
|
|
2fa8abbe41 | ||
|
|
156ccae83b | ||
|
|
7d62cf008f | ||
|
|
9fd30d8f28 | ||
|
|
70c7100014 | ||
|
|
e22322166c | ||
|
|
913f9e8855 | ||
|
|
65173d323c | ||
|
|
72f634dd90 | ||
|
|
4712e768ea | ||
|
|
7985caa9b4 | ||
|
|
e28996cf86 | ||
|
|
d994723a2d | ||
|
|
145346c18a | ||
|
|
f3e1ca13de | ||
|
|
d86ce7766c | ||
|
|
f88d14f362 | ||
|
|
3e2da2bdf8 | ||
|
|
6a60c8d73b | ||
|
|
985bac7022 | ||
|
|
aed0d63703 | ||
|
|
8446b05601 | ||
|
|
bcece9f0af | ||
|
|
3bd404f0cf | ||
|
|
9ca7089c50 | ||
|
|
adef2e9f80 | ||
|
|
2788a43dda | ||
|
|
71dac3cbb2 | ||
|
|
913e5493f5 | ||
|
|
2612f4dbcd | ||
|
|
cca0e5d461 | ||
|
|
8718339b4e | ||
|
|
ccefccf6a6 | ||
|
|
7a4defbab4 | ||
|
|
31c0479fc9 | ||
|
|
bde782f511 | ||
|
|
0cefc8f8e7 | ||
|
|
3db1bd8ac2 | ||
|
|
515b12dd0f |
@@ -0,0 +1,20 @@
|
|||||||
|
name: Chaos Monkey
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
chaos-monkey-backend:
|
||||||
|
name: Chaos Monkey (backend)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
|
env:
|
||||||
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
- name: Run backend chaos monkey
|
||||||
|
run: task chaos-monkey-backend
|
||||||
@@ -1,10 +1,35 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Dagger Remote Engine
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ jobs:
|
|||||||
linux: ${{ steps.diff.outputs.linux }}
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -141,6 +158,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -175,6 +209,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -203,6 +254,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.linux == 'true'
|
if: needs.check-changes.outputs.linux == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -236,6 +304,23 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ jobs:
|
|||||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -50,6 +67,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.has_changes == 'true'
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Print runner wait time
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
run: |
|
||||||
|
runner_start=$(date +%s)
|
||||||
|
created_at=$(curl -sf \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||||
|
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
|
||||||
|
if [ -n "$created_at" ]; then
|
||||||
|
queued_epoch=$(date -d "$created_at" +%s)
|
||||||
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
|
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
|
||||||
|
else
|
||||||
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
|
fi
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ jobs:
|
|||||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||||
name: Build & Deploy Windows (Nightly)
|
name: Build & Deploy Windows (Nightly)
|
||||||
runs-on: windows-runner
|
runs-on: windows-runner
|
||||||
|
timeout-minutes: 90
|
||||||
if: false
|
if: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
- repo: https://github.com/guettli/pre-commit-branch-up-to-date
|
- repo: https://github.com/guettli/sync-branch
|
||||||
rev: v0.0.5
|
rev: v0.0.11
|
||||||
hooks:
|
hooks:
|
||||||
- id: branch-up-to-date
|
- id: sync-branch
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
@@ -32,7 +32,7 @@ repos:
|
|||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# Snooze Feature Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
|
||||||
|
|
||||||
## Technical Approach
|
|
||||||
|
|
||||||
### 1. Metadata Storage (Account Sync)
|
|
||||||
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
|
||||||
- **JMAP:** Use `keywords`.
|
|
||||||
- **IMAP:** Use User Flags (keywords).
|
|
||||||
|
|
||||||
### 2. Database Changes
|
|
||||||
- **Migration v22:**
|
|
||||||
- `Emails` table:
|
|
||||||
- `snoozedUntil` (DateTime, nullable)
|
|
||||||
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
|
||||||
- Index on `snoozedUntil`.
|
|
||||||
|
|
||||||
### 3. Repository Updates (`EmailRepository`)
|
|
||||||
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
|
||||||
- Optimistically update local DB.
|
|
||||||
- Enqueue `snooze` change.
|
|
||||||
- New method: `Future<int> wakeUpEmails(String accountId)`
|
|
||||||
- Find local rows where `snoozedUntil <= now`.
|
|
||||||
- Enqueue `move` back to original mailbox.
|
|
||||||
- Clear snooze metadata.
|
|
||||||
|
|
||||||
### 4. Sync Loop Integration
|
|
||||||
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
|
||||||
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
|
||||||
|
|
||||||
### 5. UI Implementation
|
|
||||||
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
|
||||||
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
|
||||||
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
1. [ ] Database migration and model updates.
|
|
||||||
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
|
||||||
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
|
||||||
4. [ ] Update sync logic to parse snooze keywords.
|
|
||||||
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
|
||||||
6. [ ] UI: Snooze picker dialog.
|
|
||||||
7. [ ] UI: Add Snooze action to list and detail screens.
|
|
||||||
8. [ ] Testing and validation.
|
|
||||||
@@ -216,8 +216,3 @@ test/
|
|||||||
- **Settings** — list and remove accounts
|
- **Settings** — list and remove accounts
|
||||||
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
|
||||||
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
|
||||||
# CI Trigger
|
|
||||||
# CI Trigger 2
|
|
||||||
# Dummy commit to verify CI fixes
|
|
||||||
# Dummy commit 3
|
|
||||||
# CI Trigger 1780415300
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ tasks:
|
|||||||
run: once
|
run: once
|
||||||
deps: [_nix-check]
|
deps: [_nix-check]
|
||||||
preconditions:
|
preconditions:
|
||||||
|
- sh: '[ "$(id -u)" != "0" ]'
|
||||||
|
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
|
||||||
- sh: test -n "${IN_NIX_SHELL}"
|
- sh: test -n "${IN_NIX_SHELL}"
|
||||||
msg: "Not in nix dev shell. Run: nix develop"
|
msg: "Not in nix dev shell. Run: nix develop"
|
||||||
cmds:
|
cmds:
|
||||||
@@ -56,6 +58,14 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- echo "Setup complete."
|
- echo "Setup complete."
|
||||||
|
|
||||||
|
generate-icons:
|
||||||
|
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||||
|
deps: [_pub-get]
|
||||||
|
cmds:
|
||||||
|
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||||
|
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||||
|
- fvm flutter pub run flutter_launcher_icons
|
||||||
|
|
||||||
generate-changelog:
|
generate-changelog:
|
||||||
desc: Generate assets/changelog.txt from git history
|
desc: Generate assets/changelog.txt from git history
|
||||||
cmds:
|
cmds:
|
||||||
@@ -96,34 +106,19 @@ tasks:
|
|||||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
desc: Generate Drift DB code (run after any schema change)
|
desc: Generate Drift DB code via Dagger (exports generated files back to host)
|
||||||
deps: [_preflight, _pub-get]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
generates:
|
|
||||||
- lib/**/*.g.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
desc: Static analysis (flutter analyze)
|
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
||||||
deps: [_preflight, _codegen]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
- analysis_options.yaml
|
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_analyze.sh
|
- dagger call --progress=plain -q -m ci --source=. analyze
|
||||||
|
|
||||||
format:
|
format:
|
||||||
desc: Format all Dart source files
|
desc: Format all Dart source files via Dagger (writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- "**/*.dart"
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart format lib test
|
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
||||||
|
|
||||||
check-mocks:
|
check-mocks:
|
||||||
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||||
@@ -136,13 +131,9 @@ tasks:
|
|||||||
- scripts/check_mocks_fresh.sh
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues with dart fix --apply
|
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart fix --apply
|
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||||
@@ -177,17 +168,17 @@ tasks:
|
|||||||
test-backend:
|
test-backend:
|
||||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
sync-reliability:
|
sync-reliability:
|
||||||
desc: Run sync reliability runner (via Dagger)
|
desc: Run sync reliability runner (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
test-android-firebase:
|
test-android-firebase:
|
||||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||||
@@ -202,7 +193,7 @@ tasks:
|
|||||||
ci-graph:
|
ci-graph:
|
||||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. graph
|
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
|
||||||
stalwart:
|
stalwart:
|
||||||
desc: Start a Stalwart instance for local development (via Dagger)
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
@@ -218,13 +209,13 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle:
|
||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -234,7 +225,7 @@ tasks:
|
|||||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||||
msg: "AAB not found — run build-android-bundle first"
|
msg: "AAB not found — run build-android-bundle first"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||||
|
|
||||||
publish-android:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
@@ -247,7 +238,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
@@ -261,7 +252,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
publish-website:
|
publish-website:
|
||||||
desc: Build and publish website via Dagger
|
desc: Build and publish website via Dagger
|
||||||
@@ -271,7 +262,7 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
check-dagger:
|
check-dagger:
|
||||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
@@ -351,7 +342,7 @@ tasks:
|
|||||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
@@ -427,7 +418,7 @@ tasks:
|
|||||||
echo "Uploaded $TARBALL and updated latest.json"
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
deploy-bugreport:
|
deploy-bugreport:
|
||||||
desc: Build and deploy the Go bugreport server to the webserver
|
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
|
||||||
preconditions:
|
preconditions:
|
||||||
- sh: test -n "$SSH_USER"
|
- sh: test -n "$SSH_USER"
|
||||||
msg: "SSH_USER is not set"
|
msg: "SSH_USER is not set"
|
||||||
@@ -436,14 +427,11 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
|
|
||||||
- |
|
- |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
|
ssh "root@$SSH_HOST" "systemctl restart bugreport"
|
||||||
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
|
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
|
||||||
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
|
|
||||||
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
|
|
||||||
|
|
||||||
build-windows-release:
|
build-windows-release:
|
||||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||||
@@ -541,18 +529,10 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --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:
|
build-android-bundle-local:
|
||||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
|
dotenv: [".env"]
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
- lib/**/*.dart
|
- lib/**/*.dart
|
||||||
@@ -561,7 +541,14 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --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"
|
- 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'
|
||||||
|
|
||||||
deploy-android:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
@@ -691,8 +678,9 @@ tasks:
|
|||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
||||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. check-fast
|
||||||
|
|
||||||
check-layers:
|
check-layers:
|
||||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
@@ -742,6 +730,11 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
|
||||||
|
chaos-monkey-backend:
|
||||||
|
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
|
||||||
|
cmds:
|
||||||
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
create("release") {
|
|
||||||
// Hardcoded alias matching t.sh
|
if (ksPath != null) {
|
||||||
keyAlias = "upload"
|
signingConfigs {
|
||||||
// Use the same password for both key and keystore
|
create("release") {
|
||||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
keyAlias = "upload"
|
||||||
storePassword = pass
|
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
||||||
keyPassword = pass
|
storePassword = pass
|
||||||
storeFile = file("upload-keystore.jks")
|
keyPassword = pass
|
||||||
|
storeFile = file(ksPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,14 +48,9 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Use the signing config defined above for release builds.
|
if (ksPath != null) {
|
||||||
// If the keystore file exists (e.g. in CI or manually placed), sign it.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
|
|
||||||
signingConfigs.getByName("release")
|
|
||||||
} else {
|
|
||||||
signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -440,6 +440,68 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
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.
|
// 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
|
// It snapshots the committed source (including any stale generated files) before
|
||||||
// running build_runner, so git diff detects real staleness instead of always
|
// running build_runner, so git diff detects real staleness instead of always
|
||||||
@@ -477,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
|||||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
@@ -503,6 +565,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
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 --tags=nightly >"$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.
|
// Check runs the full check suite.
|
||||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
@@ -687,7 +759,8 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
|
|||||||
return m.androidBase().
|
return m.androidBase().
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
||||||
|
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
# Load .env into environment
|
# Load .env into environment
|
||||||
|
|||||||
@@ -48,11 +48,28 @@
|
|||||||
chmod +x $out/bin/fgj
|
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 {
|
in {
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
# Dagger CLI
|
# Dagger CLI
|
||||||
dagger.packages.${system}.dagger
|
dagger021
|
||||||
|
|
||||||
# Go compiler — for Dagger development
|
# Go compiler — for Dagger development
|
||||||
go
|
go
|
||||||
@@ -100,12 +117,16 @@
|
|||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||||
|
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
||||||
]);
|
]);
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||||
export IN_NIX_SHELL=1
|
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
|
# Disable Flutter telemetry inside dev shell
|
||||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 38;
|
const int dbSchemaVersion = 40;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
enum FilterField {
|
||||||
|
from_,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
size;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
FilterField.from_ => 'From',
|
||||||
|
FilterField.to => 'To',
|
||||||
|
FilterField.cc => 'CC',
|
||||||
|
FilterField.subject => 'Subject',
|
||||||
|
FilterField.size => 'Size (bytes)',
|
||||||
|
};
|
||||||
|
|
||||||
|
List<FilterComparison> get allowedComparisons => switch (this) {
|
||||||
|
FilterField.size => [FilterComparison.over, FilterComparison.under],
|
||||||
|
_ => [
|
||||||
|
FilterComparison.contains,
|
||||||
|
FilterComparison.is_,
|
||||||
|
FilterComparison.matches,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FilterComparison {
|
||||||
|
contains,
|
||||||
|
is_,
|
||||||
|
matches,
|
||||||
|
over,
|
||||||
|
under;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
FilterComparison.contains => 'contains',
|
||||||
|
FilterComparison.is_ => 'is',
|
||||||
|
FilterComparison.matches => 'matches',
|
||||||
|
FilterComparison.over => 'over',
|
||||||
|
FilterComparison.under => 'under',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FilterOperator { and_, or_ }
|
||||||
|
|
||||||
|
sealed class FilterNode {}
|
||||||
|
|
||||||
|
final class FilterLeaf extends FilterNode {
|
||||||
|
FilterLeaf({
|
||||||
|
required this.field,
|
||||||
|
required this.comparison,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterField field;
|
||||||
|
final FilterComparison comparison;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
FilterLeaf copyWith({
|
||||||
|
FilterField? field,
|
||||||
|
FilterComparison? comparison,
|
||||||
|
String? value,
|
||||||
|
}) =>
|
||||||
|
FilterLeaf(
|
||||||
|
field: field ?? this.field,
|
||||||
|
comparison: comparison ?? this.comparison,
|
||||||
|
value: value ?? this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FilterGroup extends FilterNode {
|
||||||
|
FilterGroup({required this.operator, required this.children});
|
||||||
|
|
||||||
|
final FilterOperator operator;
|
||||||
|
final List<FilterNode> children;
|
||||||
|
|
||||||
|
bool get isEmpty => children.isEmpty;
|
||||||
|
|
||||||
|
FilterGroup copyWith({
|
||||||
|
FilterOperator? operator,
|
||||||
|
List<FilterNode>? children,
|
||||||
|
}) =>
|
||||||
|
FilterGroup(
|
||||||
|
operator: operator ?? this.operator,
|
||||||
|
children: children ?? this.children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static FilterGroup empty() =>
|
||||||
|
FilterGroup(operator: FilterOperator.and_, children: []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
|
||||||
|
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
|
||||||
|
/// suitable for display in the visual filter editor.
|
||||||
|
///
|
||||||
|
/// Returns null if the script uses features outside the supported subset.
|
||||||
|
class FilterSieveConverter {
|
||||||
|
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
|
||||||
|
try {
|
||||||
|
final s = _Sc(script);
|
||||||
|
s.skip();
|
||||||
|
if (s.peekWord() == 'require') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
_parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
s.skip();
|
||||||
|
}
|
||||||
|
if (s.peekWord() != 'if') return null;
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final node = _parseTest(s);
|
||||||
|
if (node == null) return null;
|
||||||
|
s.skip();
|
||||||
|
s.expectChar('{');
|
||||||
|
s.skip();
|
||||||
|
final actions = <SieveAction>[];
|
||||||
|
while (s.peek() != '}' && !s.isAtEnd) {
|
||||||
|
final action = _parseAction(s);
|
||||||
|
if (action == null) return null;
|
||||||
|
actions.add(action);
|
||||||
|
s.skip();
|
||||||
|
}
|
||||||
|
s.expectChar('}');
|
||||||
|
final group = switch (node) {
|
||||||
|
final FilterGroup g => g,
|
||||||
|
final FilterLeaf l =>
|
||||||
|
FilterGroup(operator: FilterOperator.and_, children: [l]),
|
||||||
|
};
|
||||||
|
return (group: group, actions: actions);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterNode? _parseTest(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
if (word == 'allof' || word == 'anyof') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
s.expectChar('(');
|
||||||
|
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
|
||||||
|
final children = <FilterNode>[];
|
||||||
|
while (true) {
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ')') break;
|
||||||
|
final child = _parseTest(s);
|
||||||
|
if (child == null) return null;
|
||||||
|
children.add(child);
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ',') s.advance();
|
||||||
|
}
|
||||||
|
s.expectChar(')');
|
||||||
|
return FilterGroup(operator: op, children: children);
|
||||||
|
}
|
||||||
|
return _parseSingleTest(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterLeaf? _parseSingleTest(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
|
||||||
|
if (word == 'address') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final matchType = s.readTaggedArg();
|
||||||
|
s.skip();
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
final values = _parseStringOrList(s);
|
||||||
|
final field = switch (headers.firstOrNull?.toLowerCase()) {
|
||||||
|
'from' => FilterField.from_,
|
||||||
|
'to' => FilterField.to,
|
||||||
|
'cc' => FilterField.cc,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (field == null) return null;
|
||||||
|
final comp = _comp(matchType);
|
||||||
|
if (comp == null) return null;
|
||||||
|
return FilterLeaf(
|
||||||
|
field: field,
|
||||||
|
comparison: comp,
|
||||||
|
value: values.firstOrNull ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'header') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final matchType = s.readTaggedArg();
|
||||||
|
s.skip();
|
||||||
|
final headers = _parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
final values = _parseStringOrList(s);
|
||||||
|
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
|
||||||
|
final comp = _comp(matchType);
|
||||||
|
if (comp == null) return null;
|
||||||
|
return FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: comp,
|
||||||
|
value: values.firstOrNull ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (word == 'size') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final compTag = s.readTaggedArg();
|
||||||
|
s.skip();
|
||||||
|
final numStr = s.readDigits();
|
||||||
|
final comp = switch (compTag.toLowerCase()) {
|
||||||
|
':over' => FilterComparison.over,
|
||||||
|
':under' => FilterComparison.under,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (comp == null) return null;
|
||||||
|
return FilterLeaf(
|
||||||
|
field: FilterField.size,
|
||||||
|
comparison: comp,
|
||||||
|
value: numStr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
|
||||||
|
':contains' => FilterComparison.contains,
|
||||||
|
':is' => FilterComparison.is_,
|
||||||
|
':matches' => FilterComparison.matches,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
SieveAction? _parseAction(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
final word = s.peekWord()?.toLowerCase();
|
||||||
|
if (word == null) return null;
|
||||||
|
if (word == 'fileinto') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final folder = _parseString(s);
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
return FileIntoAction(folder);
|
||||||
|
}
|
||||||
|
if (word == 'keep') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
return KeepAction();
|
||||||
|
}
|
||||||
|
if (word == 'discard') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
return DiscardAction();
|
||||||
|
}
|
||||||
|
if (word == 'setflag' || word == 'addflag') {
|
||||||
|
s.readWord();
|
||||||
|
s.skip();
|
||||||
|
final flags = _parseStringOrList(s);
|
||||||
|
s.skip();
|
||||||
|
s.expectChar(';');
|
||||||
|
if (flags.any(
|
||||||
|
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||||
|
)) {
|
||||||
|
return MarkAsSeenAction();
|
||||||
|
}
|
||||||
|
return FlagAction(flags);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseStringOrList(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == '[') {
|
||||||
|
s.advance();
|
||||||
|
final items = <String>[];
|
||||||
|
while (true) {
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ']') {
|
||||||
|
s.advance();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
items.add(_parseString(s));
|
||||||
|
s.skip();
|
||||||
|
if (s.peek() == ',') s.advance();
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return [_parseString(s)];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseString(_Sc s) {
|
||||||
|
s.skip();
|
||||||
|
return s.readQuotedString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal scanner for the supported Sieve subset.
|
||||||
|
class _Sc {
|
||||||
|
_Sc(this._src);
|
||||||
|
final String _src;
|
||||||
|
int _pos = 0;
|
||||||
|
|
||||||
|
bool get isAtEnd => _pos >= _src.length;
|
||||||
|
String? peek() => isAtEnd ? null : _src[_pos];
|
||||||
|
|
||||||
|
String advance() {
|
||||||
|
if (isAtEnd) throw _ScanErr('Unexpected end');
|
||||||
|
return _src[_pos++];
|
||||||
|
}
|
||||||
|
|
||||||
|
void skip() {
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||||
|
_pos++;
|
||||||
|
} else if (ch == '#') {
|
||||||
|
while (!isAtEnd && _src[_pos] != '\n') {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||||
|
_pos += 2;
|
||||||
|
while (_pos + 1 < _src.length) {
|
||||||
|
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||||
|
_pos += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? peekWord() {
|
||||||
|
if (isAtEnd) return null;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) return ch;
|
||||||
|
if (ch == ':') {
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (end < _src.length && _wc(_src[end])) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(_pos, end).toLowerCase();
|
||||||
|
}
|
||||||
|
if (_wc(ch)) {
|
||||||
|
var end = _pos + 1;
|
||||||
|
while (end < _src.length && _wc(_src[end])) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return _src.substring(_pos, end).toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readWord() {
|
||||||
|
final start = _pos;
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if ('{}();[],'.contains(ch)) {
|
||||||
|
_pos++;
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
if (ch == ':') {
|
||||||
|
_pos++;
|
||||||
|
while (!isAtEnd && _wc(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (!isAtEnd && _wc(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _src.substring(start, _pos).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String readTaggedArg() {
|
||||||
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
|
throw _ScanErr('Expected tagged arg at $_pos');
|
||||||
|
}
|
||||||
|
|
||||||
|
String readDigits() {
|
||||||
|
final start = _pos;
|
||||||
|
while (!isAtEnd && _dig(_src[_pos])) {
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
|
||||||
|
return _src.substring(start, _pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
String readQuotedString() {
|
||||||
|
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
|
||||||
|
_pos++;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
while (!isAtEnd) {
|
||||||
|
final ch = _src[_pos];
|
||||||
|
if (ch == '"') {
|
||||||
|
_pos++;
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||||
|
_pos++;
|
||||||
|
buf.write(_src[_pos]);
|
||||||
|
_pos++;
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw _ScanErr('Unterminated string');
|
||||||
|
}
|
||||||
|
|
||||||
|
void expectChar(String ch) {
|
||||||
|
skip();
|
||||||
|
if (isAtEnd || _src[_pos] != ch) {
|
||||||
|
throw _ScanErr(
|
||||||
|
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _wc(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return (c >= 0x41 && c <= 0x5A) ||
|
||||||
|
(c >= 0x61 && c <= 0x7A) ||
|
||||||
|
(c >= 0x30 && c <= 0x39) ||
|
||||||
|
c == 0x5F ||
|
||||||
|
c == 0x2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _dig(String ch) {
|
||||||
|
final c = ch.codeUnitAt(0);
|
||||||
|
return c >= 0x30 && c <= 0x39;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanErr implements Exception {
|
||||||
|
_ScanErr(this.message);
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
@@ -192,6 +192,22 @@ class EmailThread {
|
|||||||
required this.accountId,
|
required this.accountId,
|
||||||
required this.mailboxPath,
|
required this.mailboxPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Wraps a single [Email] as a one-message thread for uniform rendering.
|
||||||
|
factory EmailThread.fromEmail(Email e) => EmailThread(
|
||||||
|
threadId: e.threadId ?? e.id,
|
||||||
|
subject: e.subject,
|
||||||
|
participants: e.from,
|
||||||
|
latestDate: e.sentAt ?? e.receivedAt,
|
||||||
|
messageCount: 1,
|
||||||
|
hasUnread: !e.isSeen,
|
||||||
|
isFlagged: e.isFlagged,
|
||||||
|
latestEmailId: e.id,
|
||||||
|
preview: e.preview,
|
||||||
|
emailIds: [e.id],
|
||||||
|
accountId: e.accountId,
|
||||||
|
mailboxPath: e.mailboxPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EmailAddress {
|
class EmailAddress {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class EmailNote {
|
||||||
|
final String id; // UUID (X-SharedInbox-Note-Id)
|
||||||
|
final String accountId;
|
||||||
|
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
|
||||||
|
final String noteText;
|
||||||
|
final String serverId; // IMAP UID (as string) or JMAP email ID
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const EmailNote({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.messageId,
|
||||||
|
required this.noteText,
|
||||||
|
required this.serverId,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
abstract class EmailRepository {
|
abstract class EmailRepository {
|
||||||
@@ -58,9 +59,15 @@ abstract class EmailRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
||||||
/// if null) by subject and preview. Fast, works offline.
|
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||||
|
|
||||||
|
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
);
|
||||||
|
|
||||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ abstract class MailboxRepository {
|
|||||||
String name,
|
String name,
|
||||||
String role,
|
String role,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Creates a new mailbox named [name] for [accountId] without a special role.
|
||||||
|
/// Returns the newly created [Mailbox].
|
||||||
|
Future<Mailbox> createMailbox(String accountId, String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
|
||||||
|
abstract class NoteRepository {
|
||||||
|
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Fetches notes from the server into the local cache.
|
||||||
|
Future<void> syncNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Creates a new note on the server and caches it locally.
|
||||||
|
Future<void> addNote(String accountId, String messageId, String text);
|
||||||
|
|
||||||
|
/// Deletes a note from the server and removes it from the local cache.
|
||||||
|
Future<void> deleteNote(String noteId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
|
||||||
|
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
|
||||||
|
/// (RFC 5228 subset).
|
||||||
|
class SieveSerializer {
|
||||||
|
String serialize(FilterGroup filter, List<SieveAction> actions) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
final requires = _collectRequires(actions);
|
||||||
|
if (requires.isNotEmpty) {
|
||||||
|
buf.writeln(
|
||||||
|
'require [${requires.map((r) => '"$r"').join(', ')}];',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.isEmpty) {
|
||||||
|
for (final a in actions) {
|
||||||
|
buf.writeln(_serializeAction(a));
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
buf.write('if ');
|
||||||
|
buf.write(_serializeNode(filter));
|
||||||
|
buf.writeln(' {');
|
||||||
|
for (final a in actions) {
|
||||||
|
buf.writeln(' ${_serializeAction(a)}');
|
||||||
|
}
|
||||||
|
buf.writeln('}');
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _collectRequires(List<SieveAction> actions) {
|
||||||
|
final req = <String>[];
|
||||||
|
for (final a in actions) {
|
||||||
|
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
|
||||||
|
if ((a is FlagAction || a is MarkAsSeenAction) &&
|
||||||
|
!req.contains('imap4flags')) {
|
||||||
|
req.add('imap4flags');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeNode(FilterNode node) => switch (node) {
|
||||||
|
final FilterLeaf leaf => _serializeLeaf(leaf),
|
||||||
|
final FilterGroup group => _serializeGroup(group),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _serializeGroup(FilterGroup group) {
|
||||||
|
if (group.isEmpty) return 'true';
|
||||||
|
if (group.children.length == 1) return _serializeNode(group.children.first);
|
||||||
|
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
|
||||||
|
final parts = group.children.map(_serializeNode).join(',\n ');
|
||||||
|
return '$op(\n $parts\n)';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
|
||||||
|
FilterField.from_ ||
|
||||||
|
FilterField.to ||
|
||||||
|
FilterField.cc =>
|
||||||
|
_serializeAddressLeaf(leaf),
|
||||||
|
FilterField.subject => _serializeHeaderLeaf(leaf),
|
||||||
|
FilterField.size => _serializeSizeLeaf(leaf),
|
||||||
|
};
|
||||||
|
|
||||||
|
String _serializeAddressLeaf(FilterLeaf leaf) {
|
||||||
|
final header = switch (leaf.field) {
|
||||||
|
FilterField.from_ => 'from',
|
||||||
|
FilterField.to => 'to',
|
||||||
|
FilterField.cc => 'cc',
|
||||||
|
_ => throw StateError('not an address field'),
|
||||||
|
};
|
||||||
|
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeHeaderLeaf(FilterLeaf leaf) =>
|
||||||
|
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
|
||||||
|
|
||||||
|
String _serializeSizeLeaf(FilterLeaf leaf) {
|
||||||
|
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
|
||||||
|
return 'size $comp ${leaf.value}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _matchType(FilterComparison comp) => switch (comp) {
|
||||||
|
FilterComparison.contains => ':contains',
|
||||||
|
FilterComparison.is_ => ':is',
|
||||||
|
FilterComparison.matches => ':matches',
|
||||||
|
_ => ':contains',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _serializeAction(SieveAction action) => switch (action) {
|
||||||
|
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
|
||||||
|
KeepAction() => 'keep;',
|
||||||
|
DiscardAction() => 'discard;',
|
||||||
|
MarkAsSeenAction() => r'setflag "\\Seen";',
|
||||||
|
final FlagAction a =>
|
||||||
|
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -318,6 +319,37 @@ class ImageTrustedSenders extends Table {
|
|||||||
Set<Column> get primaryKey => {senderEmail};
|
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).
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
@DataClassName('UserPreferencesRow')
|
@DataClassName('UserPreferencesRow')
|
||||||
class UserPreferences extends Table {
|
class UserPreferences extends Table {
|
||||||
@@ -363,6 +395,8 @@ class UserPreferences extends Table {
|
|||||||
ShareKeys,
|
ShareKeys,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
ImageTrustedSenders,
|
ImageTrustedSenders,
|
||||||
|
EmailNotes,
|
||||||
|
InstalledVersions,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -639,8 +673,33 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
userPreferences.bodyCacheLimitMb,
|
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().
|
// Resolved once in main() via initDatabasePath() before runApp().
|
||||||
@@ -735,18 +794,34 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
|||||||
void resetDatabasePathForTesting() => _dbPath = null;
|
void resetDatabasePathForTesting() => _dbPath = null;
|
||||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||||
|
|
||||||
|
/// Configures PRAGMAs on a newly opened SQLite connection.
|
||||||
|
///
|
||||||
|
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
|
||||||
|
/// instead of immediately failing.
|
||||||
|
///
|
||||||
|
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
|
||||||
|
/// WorkManager background task may already have the DB open when the app
|
||||||
|
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
|
||||||
|
/// returned in that situation; it only occurs when the DB is already in WAL
|
||||||
|
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
|
||||||
|
void _setupPragmas(Database db) {
|
||||||
|
db.execute('PRAGMA busy_timeout = 5000;');
|
||||||
|
try {
|
||||||
|
db.execute('PRAGMA journal_mode = WAL;');
|
||||||
|
} on SqliteException catch (e) {
|
||||||
|
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
|
||||||
|
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
|
||||||
|
if (e.resultCode != 5) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyDatabase _openConnection() {
|
LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final file = File(await _resolveDatabasePath());
|
final file = File(await _resolveDatabasePath());
|
||||||
return NativeDatabase.createInBackground(
|
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||||
file,
|
|
||||||
setup: (db) {
|
|
||||||
// WAL lets readers and writers proceed concurrently (different account
|
|
||||||
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
|
||||||
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
|
||||||
db.execute('PRAGMA journal_mode = WAL;');
|
|
||||||
db.execute('PRAGMA busy_timeout = 5000;');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposed so tests can run the exact production setup logic on a raw
|
||||||
|
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
|
||||||
|
void setupPragmasForTesting(Database db) => _setupPragmas(db);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -2922,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
|
|
||||||
final sql = accountId != null
|
final sql = accountId != null
|
||||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
|
||||||
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
|
||||||
final variables = accountId != null
|
final variables = accountId != null
|
||||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||||
: [Variable<String>(ftsQuery)];
|
: [Variable<String>(ftsQuery)];
|
||||||
@@ -2934,18 +2935,151 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final emailRows = await Future.wait(
|
final emailRows = await Future.wait(
|
||||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final noteRows = await _searchEmailsByNotes(accountId, null, query);
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final merged = <model.Email>[];
|
||||||
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
|
if (seen.add(e.id)) merged.add(e);
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns emails whose associated notes contain all words from [query].
|
||||||
|
/// Optionally filtered by [accountId] and [mailboxPath].
|
||||||
|
Future<List<model.Email>> _searchEmailsByNotes(
|
||||||
|
String? accountId,
|
||||||
|
String? mailboxPath,
|
||||||
|
String query,
|
||||||
|
) async {
|
||||||
|
final words =
|
||||||
|
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||||
|
if (words.isEmpty) return [];
|
||||||
|
|
||||||
|
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
|
||||||
|
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
|
||||||
|
|
||||||
|
final extraConditions = StringBuffer();
|
||||||
|
final extraVars = <Variable<String>>[];
|
||||||
|
if (accountId != null) {
|
||||||
|
extraConditions.write(' AND e.account_id = ?');
|
||||||
|
extraVars.add(Variable<String>(accountId));
|
||||||
|
}
|
||||||
|
if (mailboxPath != null) {
|
||||||
|
extraConditions.write(' AND e.mailbox_path = ?');
|
||||||
|
extraVars.add(Variable<String>(mailboxPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
final sql = 'SELECT DISTINCT e.* FROM emails e'
|
||||||
|
' JOIN email_notes n ON n.message_id = e.message_id'
|
||||||
|
' AND n.account_id = e.account_id'
|
||||||
|
' WHERE $noteConditions$extraConditions'
|
||||||
|
' ORDER BY e.received_at DESC LIMIT 50';
|
||||||
|
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
sql,
|
||||||
|
variables: [...likeVars, ...extraVars],
|
||||||
|
readsFrom: {_db.emails, _db.emailNotes},
|
||||||
|
).get();
|
||||||
|
final emailRows =
|
||||||
|
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
|
||||||
return emailRows.map(_toModel).toList();
|
return emailRows.map(_toModel).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<model.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
) async {
|
||||||
|
final rows = await (_db.select(_db.emails)
|
||||||
|
..where((t) {
|
||||||
|
final fe = _filterGroup(filter, t);
|
||||||
|
if (accountId == null) return fe;
|
||||||
|
return t.accountId.equals(accountId) & fe;
|
||||||
|
})
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||||
|
..limit(100))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toModel).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
|
||||||
|
if (group.isEmpty) return const Constant(true);
|
||||||
|
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
|
||||||
|
return switch (group.operator) {
|
||||||
|
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
|
||||||
|
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
|
||||||
|
switch (node) {
|
||||||
|
final FilterLeaf l => _filterLeaf(l, t),
|
||||||
|
final FilterGroup g => _filterGroup(g, t),
|
||||||
|
};
|
||||||
|
|
||||||
|
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
|
||||||
|
final val = leaf.value.toLowerCase();
|
||||||
|
return switch (leaf.field) {
|
||||||
|
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
|
||||||
|
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
|
||||||
|
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
|
||||||
|
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
|
||||||
|
// Size is not stored in the local cache; skip silently.
|
||||||
|
FilterField.size => const Constant(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression<bool> _jsonLike(
|
||||||
|
GeneratedColumn<String> col,
|
||||||
|
FilterComparison comp,
|
||||||
|
String val,
|
||||||
|
) =>
|
||||||
|
switch (comp) {
|
||||||
|
FilterComparison.contains => col.like('%$val%'),
|
||||||
|
FilterComparison.is_ => col.like('%"email":"$val"%'),
|
||||||
|
FilterComparison.matches => col.like(_globToLike(val)),
|
||||||
|
_ => const Constant(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
Expression<bool> _textLike(
|
||||||
|
GeneratedColumn<String> col,
|
||||||
|
FilterComparison comp,
|
||||||
|
String val,
|
||||||
|
) =>
|
||||||
|
switch (comp) {
|
||||||
|
FilterComparison.contains => col.like('%$val%'),
|
||||||
|
FilterComparison.is_ => col.like(val),
|
||||||
|
FilterComparison.matches => col.like(_globToLike(val)),
|
||||||
|
_ => const Constant(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
static String _globToLike(String glob) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < glob.length; i++) {
|
||||||
|
final ch = glob[i];
|
||||||
|
if (ch == '%' || ch == '_') {
|
||||||
|
buf.write('\\$ch');
|
||||||
|
} else if (ch == '*') {
|
||||||
|
buf.write('%');
|
||||||
|
} else if (ch == '?') {
|
||||||
|
buf.write('_');
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a user query string into an FTS5 match expression.
|
/// Converts a user query string into an FTS5 match expression.
|
||||||
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
||||||
/// partial words still match. Special FTS5 characters are stripped.
|
/// partial words still match. Special FTS5 characters are stripped.
|
||||||
static String _toFtsQuery(String query) {
|
static String _toFtsQuery(String query) {
|
||||||
final words = query
|
final words = query
|
||||||
.trim()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'[^\w]+'))
|
||||||
.where((w) => w.isNotEmpty)
|
|
||||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
|
||||||
.where((w) => w.isNotEmpty)
|
.where((w) => w.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
if (words.isEmpty) return '';
|
if (words.isEmpty) return '';
|
||||||
@@ -3047,68 +3181,42 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
// Results are limited to emails already synced into the local SQLite FTS5
|
||||||
|
// index; call syncEmails first to ensure the index is up-to-date.
|
||||||
Future<List<model.Email>> searchEmails(
|
Future<List<model.Email>> searchEmails(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async {
|
) async {
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
final ftsQuery = _toFtsQuery(query);
|
||||||
final password = await _accounts.getPassword(accountId);
|
if (ftsQuery.isEmpty) return [];
|
||||||
final client = await _imapConnect(
|
|
||||||
account,
|
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
_effectiveUsername(account),
|
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||||
password,
|
' ORDER BY e.received_at DESC LIMIT 50';
|
||||||
|
final variables = [
|
||||||
|
Variable<String>(ftsQuery),
|
||||||
|
Variable<String>(accountId),
|
||||||
|
Variable<String>(mailboxPath),
|
||||||
|
];
|
||||||
|
|
||||||
|
final queryRows = await _db
|
||||||
|
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||||
|
final emailRows = await Future.wait(
|
||||||
|
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
await client.selectMailboxByPath(mailboxPath);
|
|
||||||
final terms =
|
|
||||||
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
|
||||||
final searchCriteria = terms.map((term) {
|
|
||||||
final escaped = term.replaceAll('"', '\\"');
|
|
||||||
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
|
|
||||||
}).join(' ');
|
|
||||||
final result = await client.uidSearchMessages(
|
|
||||||
searchCriteria: searchCriteria,
|
|
||||||
);
|
|
||||||
final uids = result.matchingSequence?.toList() ?? [];
|
|
||||||
if (uids.isEmpty) return [];
|
|
||||||
|
|
||||||
final fetch = await client.uidFetchMessages(
|
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||||
imap.MessageSequence.fromIds(uids, isUid: true),
|
|
||||||
'(UID FLAGS ENVELOPE)',
|
final seen = <String>{};
|
||||||
);
|
final merged = <model.Email>[];
|
||||||
return fetch.messages
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
.where((msg) => msg.uid != null && msg.envelope != null)
|
if (seen.add(e.id)) merged.add(e);
|
||||||
.map((msg) {
|
|
||||||
final envelope = msg.envelope!;
|
|
||||||
final uid = msg.uid!;
|
|
||||||
final emailId = '$accountId:$uid';
|
|
||||||
return model.Email(
|
|
||||||
id: emailId,
|
|
||||||
accountId: accountId,
|
|
||||||
mailboxPath: mailboxPath,
|
|
||||||
uid: uid,
|
|
||||||
subject: envelope.subject,
|
|
||||||
sentAt: envelope.date,
|
|
||||||
receivedAt: envelope.date ?? DateTime.now(),
|
|
||||||
from: _toAddressList(envelope.from),
|
|
||||||
to: _toAddressList(envelope.to),
|
|
||||||
cc: _toAddressList(envelope.cc),
|
|
||||||
isSeen: msg.flags?.contains(r'\Seen') ?? false,
|
|
||||||
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
|
||||||
hasAttachment: msg.hasAttachments(),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
}
|
||||||
|
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
|
|
||||||
(addresses ?? const [])
|
|
||||||
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Computes a stable threadId from RFC 2822 headers.
|
/// Computes a stable threadId from RFC 2822 headers.
|
||||||
|
|||||||
@@ -343,11 +343,23 @@ 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(
|
Future<model.Mailbox> _createMailboxWithRoleImap(
|
||||||
account_model.Account account,
|
account_model.Account account,
|
||||||
String password,
|
String password,
|
||||||
String name,
|
String name,
|
||||||
String role,
|
String? role,
|
||||||
) async {
|
) async {
|
||||||
final client = await _imapConnect(
|
final client = await _imapConnect(
|
||||||
account,
|
account,
|
||||||
@@ -380,7 +392,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
account_model.Account account,
|
account_model.Account account,
|
||||||
String password,
|
String password,
|
||||||
String name,
|
String name,
|
||||||
String role,
|
String? role,
|
||||||
) async {
|
) async {
|
||||||
final jmapUrl = account.jmapUrl;
|
final jmapUrl = account.jmapUrl;
|
||||||
if (jmapUrl == null || jmapUrl.isEmpty) {
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
@@ -398,7 +410,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
{
|
{
|
||||||
'accountId': jmap.accountId,
|
'accountId': jmap.accountId,
|
||||||
'create': {
|
'create': {
|
||||||
'new-mailbox': {'name': name, 'role': role},
|
'new-mailbox': {
|
||||||
|
'name': name,
|
||||||
|
if (role != null) 'role': role,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||||
|
|
||||||
|
const _notesFolder = 'Notes';
|
||||||
|
const _headerNoteFor = 'X-SharedInbox-Note-For';
|
||||||
|
const _headerNoteId = 'X-SharedInbox-Note-Id';
|
||||||
|
|
||||||
|
class NoteRepositoryImpl implements NoteRepository {
|
||||||
|
NoteRepositoryImpl(
|
||||||
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn imapConnect = connectImap,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) : _imapConnect = imapConnect,
|
||||||
|
_httpClient = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accounts;
|
||||||
|
final ImapConnectFn _imapConnect;
|
||||||
|
final http.Client _httpClient;
|
||||||
|
|
||||||
|
String _effectiveUsername(account_model.Account account) =>
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
|
||||||
|
// ── Observe (local cache) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
|
||||||
|
return (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||||
|
)
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toModel).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync (server → local cache) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncNotes(String accountId, String messageId) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _syncNotesImap(account, password, messageId);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _syncNotesJmap(account, password, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder doesn't exist — nothing to sync.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||||
|
final searchResult = await client.uidSearchMessages(
|
||||||
|
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
|
||||||
|
);
|
||||||
|
final uids = searchResult.matchingSequence?.toList() ?? [];
|
||||||
|
|
||||||
|
if (uids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
|
||||||
|
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final msg in fetch.messages) {
|
||||||
|
final uid = msg.uid;
|
||||||
|
if (uid == null) continue;
|
||||||
|
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: msg.decodeTextPlainPart() ?? '',
|
||||||
|
serverId: uid.toString(),
|
||||||
|
createdAt: msg.decodeDate() ?? DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes (deleted on the server).
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (mailboxId == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'filter': {'inMailbox': mailboxId},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final ids = List<String>.from(
|
||||||
|
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final getResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': ids,
|
||||||
|
'properties': [
|
||||||
|
'id',
|
||||||
|
'receivedAt',
|
||||||
|
'textBody',
|
||||||
|
'bodyValues',
|
||||||
|
'header:$_headerNoteFor:asText',
|
||||||
|
'header:$_headerNoteId:asText',
|
||||||
|
],
|
||||||
|
'fetchTextBodyValues': true,
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list =
|
||||||
|
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final e in list) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
|
||||||
|
if (noteFor != messageId) continue;
|
||||||
|
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
final jmapEmailId = m['id'] as String;
|
||||||
|
|
||||||
|
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
|
||||||
|
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
|
||||||
|
var noteText = '';
|
||||||
|
if (textBodyParts.isNotEmpty) {
|
||||||
|
final partId =
|
||||||
|
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
|
||||||
|
if (partId != null) {
|
||||||
|
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
|
||||||
|
as String? ??
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final createdAt =
|
||||||
|
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: noteText,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes.
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) & t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addNote(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
final noteId = _generateId();
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _addNoteImap(account, password, messageId, noteId, text);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _addNoteJmap(account, password, messageId, noteId, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.createMailbox(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Already exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
final builder = imap.MessageBuilder()
|
||||||
|
..subject = 'Note'
|
||||||
|
..text = text;
|
||||||
|
builder.addHeader(_headerNoteFor, messageId);
|
||||||
|
builder.addHeader(_headerNoteId, noteId);
|
||||||
|
final mime = builder.buildMimeMessage();
|
||||||
|
|
||||||
|
final appendResult = await client.appendMessage(
|
||||||
|
mime,
|
||||||
|
targetMailboxPath: _notesFolder,
|
||||||
|
);
|
||||||
|
final uidList =
|
||||||
|
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||||
|
final serverId = (uidList != null && uidList.isNotEmpty)
|
||||||
|
? uidList.first.toString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: serverId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
|
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
|
||||||
|
|
||||||
|
const bodyPartId = '1';
|
||||||
|
final setResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-note': {
|
||||||
|
'mailboxIds': {mailboxId: true},
|
||||||
|
'subject': 'Note',
|
||||||
|
'keywords': {r'$seen': true},
|
||||||
|
'headers': [
|
||||||
|
{'name': _headerNoteFor, 'value': ' $messageId'},
|
||||||
|
{'name': _headerNoteId, 'value': ' $noteId'},
|
||||||
|
],
|
||||||
|
'bodyValues': {
|
||||||
|
bodyPartId: {
|
||||||
|
'value': text,
|
||||||
|
'isEncodingProblem': false,
|
||||||
|
'isTruncated': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'textBody': [
|
||||||
|
{'partId': bodyPartId, 'type': 'text/plain'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
final result = _responseArgs(setResp, 0, 'Email/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newEmail = created?['new-note'] as Map<String, dynamic>?;
|
||||||
|
final jmapEmailId = newEmail?['id'] as String? ?? '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteNote(String noteId) async {
|
||||||
|
final noteRow = await (_db.select(_db.emailNotes)
|
||||||
|
..where((t) => t.id.equals(noteId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (noteRow == null) return;
|
||||||
|
|
||||||
|
final account = await _accounts.getAccount(noteRow.accountId);
|
||||||
|
if (account == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _deleteNoteImap(account, password, noteRow);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _deleteNoteJmap(account, password, noteRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
final uid = int.tryParse(noteRow.serverId);
|
||||||
|
if (uid != null) {
|
||||||
|
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||||
|
await client.uidMarkDeleted(seq);
|
||||||
|
await client.uidExpunge(seq);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder gone or message already deleted — clean up locally.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noteRow.serverId.isNotEmpty) {
|
||||||
|
await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'destroy': [noteRow.serverId],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JMAP helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/get',
|
||||||
|
{'accountId': jmap.accountId, 'ids': null},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
|
||||||
|
for (final m in list) {
|
||||||
|
final map = m as Map<String, dynamic>;
|
||||||
|
if (map['name'] == _notesFolder) return map['id'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final existing = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-notes': {'name': _notesFolder},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(resp, 0, 'Mailbox/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
|
||||||
|
return newMailbox?['id'] as String? ?? _notesFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _responseArgs(
|
||||||
|
List<dynamic> responses,
|
||||||
|
int index,
|
||||||
|
String expectedMethod,
|
||||||
|
) {
|
||||||
|
final triple = responses[index] as List<dynamic>;
|
||||||
|
final method = triple[0] as String;
|
||||||
|
if (method == 'error') {
|
||||||
|
final err = triple[1] as Map<String, dynamic>;
|
||||||
|
throw JmapException('$expectedMethod error: ${err['type']}');
|
||||||
|
}
|
||||||
|
return triple[1] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailNote _toModel(EmailNoteRow row) => EmailNote(
|
||||||
|
id: row.id,
|
||||||
|
accountId: row.accountId,
|
||||||
|
messageId: row.messageId,
|
||||||
|
noteText: row.noteText,
|
||||||
|
serverId: row.serverId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generates a random UUID v4.
|
||||||
|
static String _generateId() {
|
||||||
|
final rng = math.Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
|
||||||
|
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
|
||||||
|
'-${hex.substring(20)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
@@ -32,6 +34,7 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
|||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
@@ -282,3 +285,22 @@ final trustedImageSendersProvider =
|
|||||||
.watch(userPreferencesRepositoryProvider)
|
.watch(userPreferencesRepositoryProvider)
|
||||||
.observeTrustedImageSenders();
|
.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),
|
||||||
|
);
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||||
ref.read(syncManagerProvider).start();
|
ref.read(syncManagerProvider).start();
|
||||||
ref.read(reliabilityRunnerProvider).start();
|
ref.read(reliabilityRunnerProvider).start();
|
||||||
|
if (_kGitHash.isNotEmpty) {
|
||||||
|
unawaited(
|
||||||
|
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -102,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
@@ -109,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
@@ -21,6 +22,8 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
@@ -54,6 +57,14 @@ final router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'undo-log',
|
path: 'undo-log',
|
||||||
builder: (ctx, state) => const UndoLogScreen(),
|
builder: (ctx, state) => const UndoLogScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':actionId',
|
||||||
|
builder: (ctx, state) => UndoLogDetailScreen(
|
||||||
|
action: state.extra as UndoAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
@@ -67,6 +78,12 @@ final router = GoRouter(
|
|||||||
path: 'preferences',
|
path: 'preferences',
|
||||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'trusted-senders',
|
||||||
|
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||||
|
highlightedSender: state.extra as String?,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
|
|||||||
@@ -2,21 +2,90 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChangeLogScreen extends StatelessWidget {
|
class ChangeLogScreen extends ConsumerWidget {
|
||||||
const ChangeLogScreen({super.key});
|
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}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
|
||||||
|
|
||||||
|
static final _issueRefPattern = RegExp(r'#(\d+)');
|
||||||
|
|
||||||
|
static String _linkifyIssueRefs(String text) {
|
||||||
|
return text.replaceAllMapped(
|
||||||
|
_issueRefPattern,
|
||||||
|
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final installedVersions = ref.watch(installedVersionsProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future: DefaultAssetBundle.of(
|
future:
|
||||||
context,
|
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||||
).loadString('assets/changelog.txt'),
|
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||||
|
installedVersions.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
@@ -24,9 +93,12 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
child: Text('Error loading changelog: ${snapshot.error}'),
|
child: Text('Error loading changelog: ${snapshot.error}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final content = snapshot.data ?? 'No changelog entries found.';
|
final raw = snapshot.data ?? 'No changelog entries found.';
|
||||||
|
final content = _linkifyIssueRefs(raw);
|
||||||
|
final versions = installedVersions.value ?? {};
|
||||||
|
final annotated = _injectInstallMarkers(content, versions);
|
||||||
return Markdown(
|
return Markdown(
|
||||||
data: content,
|
data: annotated,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -3,20 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/di.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 {
|
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||||
const CombinedInboxScreen({super.key});
|
const CombinedInboxScreen({super.key});
|
||||||
@@ -30,6 +22,31 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
int _limit = _pageSize;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accountsAsync = ref.watch(allAccountsProvider);
|
final accountsAsync = ref.watch(allAccountsProvider);
|
||||||
@@ -58,18 +75,38 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(accounts),
|
appBar: _buildAppBar(accounts),
|
||||||
drawer: _buildDrawer(context, accounts),
|
drawer: _selecting ? null : _buildDrawer(context, accounts),
|
||||||
|
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||||
body: _buildBody(accountNames, showAccount),
|
body: _buildBody(accountNames, showAccount),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: _selecting
|
||||||
onPressed: () => context.push('/compose'),
|
? null
|
||||||
child: const Icon(Icons.edit),
|
: FloatingActionButton(
|
||||||
),
|
onPressed: () => context.push('/compose'),
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
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(
|
return AppBar(
|
||||||
title: const Text('Combined Inbox'),
|
title: const Text('Combined Inbox'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -91,6 +128,26 @@ 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) {
|
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||||
return Drawer(
|
return Drawer(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -176,6 +233,7 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
final threads = snap.data!;
|
final threads = snap.data!;
|
||||||
|
_currentThreads = threads;
|
||||||
if (threads.isEmpty) {
|
if (threads.isEmpty) {
|
||||||
return ListView(
|
return ListView(
|
||||||
children: const [
|
children: const [
|
||||||
@@ -207,119 +265,33 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
child: const Text('Load more'),
|
child: const Text('Load more'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
|
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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
Future<void> _onSwipeDismissed(
|
||||||
EmailThread t,
|
EmailThread t,
|
||||||
DismissDirection direction,
|
DismissDirection direction,
|
||||||
@@ -370,24 +342,81 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _swipeBackground({
|
Future<void> _batchArchive() async {
|
||||||
required AlignmentGeometry alignment,
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
required Color color,
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
// Group selected threads by accountId so we look up each account's archive once.
|
||||||
}) {
|
final byAccount = <String, List<EmailThread>>{};
|
||||||
return Container(
|
for (final t in _currentThreads) {
|
||||||
color: color,
|
if (!_selectedThreadIds.contains(t.threadId)) continue;
|
||||||
alignment: alignment,
|
(byAccount[t.accountId] ??= []).add(t);
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
}
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
_clearSelection();
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.white),
|
for (final entry in byAccount.entries) {
|
||||||
const SizedBox(width: 8),
|
final accountId = entry.key;
|
||||||
Text(label, style: const TextStyle(color: Colors.white)),
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Something went wrong'),
|
title: const Text('Something went wrong'),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
@@ -37,6 +38,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
bool _isFlagged = false;
|
bool _isFlagged = false;
|
||||||
bool _loadRemoteImages = false;
|
bool _loadRemoteImages = false;
|
||||||
final Set<String> _downloading = {};
|
final Set<String> _downloading = {};
|
||||||
|
bool _notesSynced = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -50,6 +52,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (email != null && mounted) {
|
if (email != null && mounted) {
|
||||||
setState(() => _isFlagged = email.isFlagged);
|
setState(() => _isFlagged = email.isFlagged);
|
||||||
}
|
}
|
||||||
|
if (!_notesSynced && email?.messageId != null) {
|
||||||
|
_notesSynced = true;
|
||||||
|
unawaited(
|
||||||
|
ref.read(noteRepositoryProvider).syncNotes(
|
||||||
|
email!.accountId,
|
||||||
|
email.messageId!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -229,11 +240,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'Settings',
|
label: 'View',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
unawaited(
|
unawaited(
|
||||||
context.push('/accounts/preferences'),
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -254,6 +268,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
body.textBody ?? '',
|
body.textBody ?? '',
|
||||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
if (header?.messageId != null) _buildNotesSection(ctx, header!),
|
||||||
if (body.attachments.isNotEmpty) ...[
|
if (body.attachments.isNotEmpty) ...[
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -337,6 +352,114 @@ 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) {
|
Widget _buildHeader(BuildContext ctx, Email email) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -560,6 +683,42 @@ 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 {
|
Future<void> _moveTo(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
|
||||||
@@ -573,6 +732,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
const createNewSentinel = '__create_new__';
|
||||||
|
|
||||||
final chosen = await showModalBottomSheet<String>(
|
final chosen = await showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => ListView(
|
builder: (ctx) => ListView(
|
||||||
@@ -590,13 +751,28 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
title: Text(m.name),
|
title: Text(m.name),
|
||||||
onTap: () => Navigator.pop(ctx, m.path),
|
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;
|
if (chosen == null || !context.mounted) return;
|
||||||
|
|
||||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
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);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
@@ -606,7 +782,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
type: UndoType.move,
|
type: UndoType.move,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
destinationMailboxPath: chosen,
|
destinationMailboxPath: destination,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,19 +12,10 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
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 {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
@@ -59,6 +50,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Pagination: number of threads currently requested from the DB.
|
// Pagination: number of threads currently requested from the DB.
|
||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
int _limit = _pageSize;
|
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 =>
|
bool get _selecting =>
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = null;
|
_searchResults = null;
|
||||||
_searchLoading = false;
|
_searchLoading = false;
|
||||||
|
_lastSettledQuery = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -126,18 +127,35 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runSearch(String query) async {
|
Future<void> _runSearch(String query) async {
|
||||||
if (query.trim().isEmpty) {
|
final q = query.trim();
|
||||||
setState(() => _searchResults = null);
|
if (q.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = null;
|
||||||
|
_lastSettledQuery = null;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Skip if results are already settled for this exact query — prevents the
|
||||||
|
// Enter key from re-triggering a search that already completed.
|
||||||
|
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final generation = ++_searchGeneration;
|
||||||
setState(() => _searchLoading = true);
|
setState(() => _searchLoading = true);
|
||||||
try {
|
try {
|
||||||
final results = await ref
|
final results = await ref
|
||||||
.read(emailRepositoryProvider)
|
.read(emailRepositoryProvider)
|
||||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
.searchEmails(widget.accountId, widget.mailboxPath, q);
|
||||||
if (mounted) setState(() => _searchResults = results);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = results;
|
||||||
|
_lastSettledQuery = q;
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _searchLoading = false);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() => _searchLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,8 +568,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
if (wasSearching && mounted) {
|
if (wasSearching && mounted) {
|
||||||
// Filter deleted emails out of the local results immediately.
|
// Filter deleted emails out of the local results immediately.
|
||||||
// Calling searchEmails here would hit the IMAP server, which still has
|
// Calling searchEmails here would still return deleted rows because the
|
||||||
// the emails because the delete is only enqueued — not yet applied.
|
// delete is only enqueued — not yet applied to the local DB.
|
||||||
final deletedIds = ids.toSet();
|
final deletedIds = ids.toSet();
|
||||||
final remaining = (_searchResults ?? [])
|
final remaining = (_searchResults ?? [])
|
||||||
.where((e) => !deletedIds.contains(e.id))
|
.where((e) => !deletedIds.contains(e.id))
|
||||||
@@ -688,177 +706,93 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final t = threads[i];
|
final t = threads[i];
|
||||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
return EmailThreadTile(
|
||||||
final senderNames =
|
thread: t,
|
||||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||||
|
isSelecting: _selecting,
|
||||||
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
|
onTap: _selecting
|
||||||
? () => _toggleThreadSelection(t)
|
? () => _toggleThreadSelection(t)
|
||||||
: t.messageCount > 1
|
: t.messageCount > 1
|
||||||
? () => context.push(
|
? () => 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(
|
: () => 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),
|
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.
|
// Used for search results, which are individual emails.
|
||||||
Widget _buildEmailList(List<Email> emails) {
|
Widget _buildEmailList(List<Email> emails) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: emails.length,
|
itemCount: emails.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final e = emails[i];
|
final e = emails[i];
|
||||||
|
final t = EmailThread.fromEmail(e);
|
||||||
final isSelected = _selectedSearchIds.contains(e.id);
|
final isSelected = _selectedSearchIds.contains(e.id);
|
||||||
return EmailTile(
|
return ThreadTile(
|
||||||
email: e,
|
thread: t,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -877,25 +811,4 @@ 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,11 +4,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
|
|
||||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||||
ref,
|
ref,
|
||||||
@@ -37,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _fieldFocused = false;
|
bool _fieldFocused = false;
|
||||||
|
|
||||||
|
// Advanced (structured) search state.
|
||||||
|
bool _advancedMode = false;
|
||||||
|
FilterGroup _filterGroup = FilterGroup.empty();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -53,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleAdvanced() {
|
||||||
|
setState(() {
|
||||||
|
_advancedMode = !_advancedMode;
|
||||||
|
_results = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onChanged(String value) {
|
void _onChanged(String value) {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
if (value.trim().length < 3) {
|
if (value.trim().length < 3) {
|
||||||
@@ -135,22 +148,47 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _searchStructured() async {
|
||||||
|
if (_filterGroup.isEmpty) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final emails = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.searchEmailsStructured(widget.accountId, _filterGroup);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_results = _SearchResults(
|
||||||
|
mailboxes: const [],
|
||||||
|
addresses: const [],
|
||||||
|
emails: emails,
|
||||||
|
);
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('Structured search failed: $e');
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextField(
|
title: _advancedMode
|
||||||
controller: _ctrl,
|
? const Text('Advanced Search')
|
||||||
focusNode: _focusNode,
|
: TextField(
|
||||||
autofocus: true,
|
controller: _ctrl,
|
||||||
decoration: const InputDecoration(
|
focusNode: _focusNode,
|
||||||
hintText: 'Search folders, addresses, emails…',
|
autofocus: true,
|
||||||
border: InputBorder.none,
|
decoration: const InputDecoration(
|
||||||
),
|
hintText: 'Search folders, addresses, emails…',
|
||||||
onChanged: _onChanged,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
|
onChanged: _onChanged,
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_ctrl.text.isNotEmpty)
|
if (!_advancedMode && _ctrl.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -158,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
setState(() => _results = null);
|
setState(() => _results = null);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_advancedMode ? Icons.search : Icons.tune,
|
||||||
|
color:
|
||||||
|
_advancedMode ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
tooltip: _advancedMode ? 'Simple search' : 'Advanced search',
|
||||||
|
onPressed: _toggleAdvanced,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
@@ -165,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
|
if (_advancedMode) return _buildAdvancedBody();
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_results == null) {
|
if (_results == null) {
|
||||||
if (_fieldFocused && _ctrl.text.isEmpty) {
|
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||||
@@ -174,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
final r = _results!;
|
final r = _results!;
|
||||||
if (r.isEmpty) return const Center(child: Text('No results'));
|
if (r.isEmpty) return const Center(child: Text('No results'));
|
||||||
|
return _buildResultsList(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdvancedBody() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilterBuilderWidget(
|
||||||
|
initialValue: _filterGroup,
|
||||||
|
onChanged: (g) => setState(() {
|
||||||
|
_filterGroup = g;
|
||||||
|
_results = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _filterGroup.isEmpty ? null : _searchStructured,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: const Text('Search'),
|
||||||
|
),
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (_results != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_results!.isEmpty)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text('No results'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildResultsList(_results!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultsList(_SearchResults r) {
|
||||||
return ListView(
|
return ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
if (r.mailboxes.isNotEmpty) ...[
|
if (r.mailboxes.isNotEmpty) ...[
|
||||||
const _SectionHeader('Folders'),
|
const _SectionHeader('Folders'),
|
||||||
@@ -189,9 +284,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
if (r.emails.isNotEmpty) ...[
|
if (r.emails.isNotEmpty) ...[
|
||||||
const _SectionHeader('Messages'),
|
const _SectionHeader('Messages'),
|
||||||
for (final e in r.emails)
|
for (final e in r.emails)
|
||||||
EmailTile(
|
ThreadTile(
|
||||||
email: e,
|
thread: EmailThread.fromEmail(e),
|
||||||
showLocation: true,
|
locationLabel: '${e.accountId} • ${e.mailboxPath}',
|
||||||
onTap: () => context.push(
|
onTap: () => context.push(
|
||||||
'/accounts/${e.accountId}/mailboxes'
|
'/accounts/${e.accountId}/mailboxes'
|
||||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
|
||||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||||
|
|
||||||
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||||
const SieveScriptEditScreen({
|
const SieveScriptEditScreen({
|
||||||
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
|||||||
_SieveScriptEditScreenState();
|
_SieveScriptEditScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late final TextEditingController _nameController;
|
late final TextEditingController _nameController;
|
||||||
late final TextEditingController _contentController;
|
late final TextEditingController _contentController;
|
||||||
|
late final TabController _tabController;
|
||||||
|
|
||||||
bool _loadingContent = false;
|
bool _loadingContent = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
// Visual-editor state.
|
||||||
|
FilterGroup _filterGroup = FilterGroup.empty();
|
||||||
|
List<SieveAction> _actions = [];
|
||||||
|
bool _visualSupported = true;
|
||||||
|
int _visualLoadCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
||||||
_contentController = TextEditingController();
|
_contentController = TextEditingController();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
if (widget.script != null) {
|
if (widget.script != null) {
|
||||||
unawaited(_loadContent());
|
unawaited(_loadContent());
|
||||||
}
|
}
|
||||||
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_contentController.dispose();
|
_contentController.dispose();
|
||||||
|
_tabController
|
||||||
|
..removeListener(_onTabChanged)
|
||||||
|
..dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onTabChanged() {
|
||||||
|
if (_tabController.indexIsChanging) return;
|
||||||
|
if (_tabController.index == 1) {
|
||||||
|
// Switched to Script tab: serialize visual state.
|
||||||
|
if (_visualSupported) {
|
||||||
|
_contentController.text =
|
||||||
|
SieveSerializer().serialize(_filterGroup, _actions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Switched to Visual tab: parse script into visual state.
|
||||||
|
_parseScriptIntoVisual();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseScriptIntoVisual() {
|
||||||
|
final result = FilterSieveConverter().parse(_contentController.text);
|
||||||
|
if (result == null) {
|
||||||
|
setState(() => _visualSupported = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_filterGroup = result.group;
|
||||||
|
_actions = List<SieveAction>.from(result.actions);
|
||||||
|
_visualSupported = true;
|
||||||
|
_visualLoadCount++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadContent() async {
|
Future<void> _loadContent() async {
|
||||||
setState(() => _loadingContent = true);
|
setState(() => _loadingContent = true);
|
||||||
try {
|
try {
|
||||||
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_contentController.text = content;
|
_contentController.text = content;
|
||||||
|
_parseScriptIntoVisual();
|
||||||
setState(() => _loadingContent = false);
|
setState(() => _loadingContent = false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
|
// Sync visual → script if on visual tab.
|
||||||
|
if (_tabController.index == 0 && _visualSupported) {
|
||||||
|
_contentController.text =
|
||||||
|
SieveSerializer().serialize(_filterGroup, _actions);
|
||||||
|
}
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
setState(() => _error = 'Name is required');
|
setState(() => _error = 'Name is required');
|
||||||
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(isNew ? 'New script' : 'Edit script'),
|
title: Text(isNew ? 'New script' : 'Edit script'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_saving)
|
if (_saving)
|
||||||
const Padding(
|
const Padding(
|
||||||
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TabBarView(
|
||||||
controller: _contentController,
|
controller: _tabController,
|
||||||
decoration: const InputDecoration(
|
children: [_buildVisualTab(), _buildScriptTab()],
|
||||||
labelText: 'Script',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
expands: true,
|
|
||||||
textAlignVertical: TextAlignVertical.top,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace'),
|
|
||||||
enabled: !_saving,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildVisualTab() {
|
||||||
|
if (!_visualSupported) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'This script uses features not supported by the visual editor.\n'
|
||||||
|
'Edit as raw Sieve on the Script tab.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FilterBuilderWidget(
|
||||||
|
key: ValueKey(_visualLoadCount),
|
||||||
|
initialValue: _filterGroup,
|
||||||
|
onChanged: (g) => setState(() => _filterGroup = g),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ActionEditor(
|
||||||
|
actions: _actions,
|
||||||
|
onChanged: (a) => setState(() => _actions = a),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScriptTab() {
|
||||||
|
return TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Script',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
enabled: !_saving,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action editor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum _ActionType { keep, discard, markAsRead, fileInto }
|
||||||
|
|
||||||
|
class _ActionEditor extends StatelessWidget {
|
||||||
|
const _ActionEditor({required this.actions, required this.onChanged});
|
||||||
|
|
||||||
|
final List<SieveAction> actions;
|
||||||
|
final void Function(List<SieveAction>) onChanged;
|
||||||
|
|
||||||
|
_ActionType _typeOf(SieveAction a) => switch (a) {
|
||||||
|
KeepAction() => _ActionType.keep,
|
||||||
|
DiscardAction() => _ActionType.discard,
|
||||||
|
MarkAsSeenAction() => _ActionType.markAsRead,
|
||||||
|
FileIntoAction() => _ActionType.fileInto,
|
||||||
|
FlagAction() => _ActionType.keep,
|
||||||
|
};
|
||||||
|
|
||||||
|
SieveAction _defaultFor(_ActionType t) => switch (t) {
|
||||||
|
_ActionType.keep => KeepAction(),
|
||||||
|
_ActionType.discard => DiscardAction(),
|
||||||
|
_ActionType.markAsRead => MarkAsSeenAction(),
|
||||||
|
_ActionType.fileInto => FileIntoAction(''),
|
||||||
|
};
|
||||||
|
|
||||||
|
void _changeType(int i, _ActionType t) {
|
||||||
|
final next = List<SieveAction>.from(actions);
|
||||||
|
final current = next[i];
|
||||||
|
if (t == _ActionType.fileInto && current is FileIntoAction) return;
|
||||||
|
next[i] = _defaultFor(t);
|
||||||
|
onChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _changeFolder(int i, String folder) {
|
||||||
|
final next = List<SieveAction>.from(actions);
|
||||||
|
next[i] = FileIntoAction(folder);
|
||||||
|
onChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _remove(int i) {
|
||||||
|
final next = List<SieveAction>.from(actions)..removeAt(i);
|
||||||
|
onChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _add() {
|
||||||
|
onChanged([...actions, KeepAction()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text('Actions', style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
),
|
||||||
|
for (var i = 0; i < actions.length; i++) _buildRow(context, i),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _add,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add action'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow(BuildContext context, int i) {
|
||||||
|
final action = actions[i];
|
||||||
|
final type = _typeOf(action);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
DropdownButton<_ActionType>(
|
||||||
|
value: type,
|
||||||
|
isDense: true,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
onChanged: (t) {
|
||||||
|
if (t != null) _changeType(i, t);
|
||||||
|
},
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _ActionType.discard,
|
||||||
|
child: Text('Discard'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _ActionType.markAsRead,
|
||||||
|
child: Text('Mark as read'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: _ActionType.fileInto,
|
||||||
|
child: Text('File into'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (type == _ActionType.fileInto) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _FolderField(
|
||||||
|
value: (action as FileIntoAction).folder,
|
||||||
|
onChanged: (v) => _changeFolder(i, v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () => _remove(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FolderField extends StatefulWidget {
|
||||||
|
const _FolderField({required this.value, required this.onChanged});
|
||||||
|
final String value;
|
||||||
|
final void Function(String) onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FolderField> createState() => _FolderFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FolderFieldState extends State<_FolderField> {
|
||||||
|
late final TextEditingController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = TextEditingController(text: widget.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_FolderField old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.value != _ctrl.text) _ctrl.text = widget.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
onChanged: widget.onChanged,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'folder',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,11 +217,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'Settings',
|
label: 'View',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
unawaited(
|
unawaited(
|
||||||
context.push('/accounts/preferences'),
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
onTap: () => context.go(
|
||||||
|
'/accounts/undo-log/${action.id}',
|
||||||
|
extra: action,
|
||||||
|
),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
action.type == UndoType.delete
|
action.type == UndoType.delete
|
||||||
? Icons.delete_outline
|
? Icons.delete_outline
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
@@ -14,6 +15,7 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||||
|
final trustedCount = trustedSendersAsync.value?.length ?? 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Preferences')),
|
appBar: AppBar(title: const Text('Preferences')),
|
||||||
@@ -213,41 +215,16 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Trusted image senders',
|
'Allowed addresses for images',
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
subtitle: const Text(
|
subtitle: Text(
|
||||||
'Remote images are loaded automatically for these senders.',
|
trustedCount == 0
|
||||||
|
? 'No addresses added yet.'
|
||||||
|
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
|
||||||
),
|
),
|
||||||
),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
...trustedSendersAsync.when(
|
onTap: () => context.push('/accounts/trusted-senders'),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
|
||||||
|
/// A widget that lets the user build a structured [FilterGroup] interactively.
|
||||||
|
///
|
||||||
|
/// Use a [ValueKey] on this widget when replacing [initialValue] from the
|
||||||
|
/// outside (e.g., after loading a Sieve script) to force a full rebuild.
|
||||||
|
class FilterBuilderWidget extends StatefulWidget {
|
||||||
|
const FilterBuilderWidget({
|
||||||
|
super.key,
|
||||||
|
required this.initialValue,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterGroup initialValue;
|
||||||
|
final void Function(FilterGroup) onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FilterBuilderWidget> createState() => _FilterBuilderWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterBuilderWidgetState extends State<FilterBuilderWidget> {
|
||||||
|
late FilterGroup _group;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_group = widget.initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _update(FilterGroup g) {
|
||||||
|
setState(() => _group = g);
|
||||||
|
widget.onChanged(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _GroupEditor(
|
||||||
|
group: _group,
|
||||||
|
onChanged: _update,
|
||||||
|
depth: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group editor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _GroupEditor extends StatelessWidget {
|
||||||
|
const _GroupEditor({
|
||||||
|
super.key,
|
||||||
|
required this.group,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.depth,
|
||||||
|
this.onRemoveGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterGroup group;
|
||||||
|
final void Function(FilterGroup) onChanged;
|
||||||
|
final int depth;
|
||||||
|
final VoidCallback? onRemoveGroup;
|
||||||
|
|
||||||
|
static const _maxDepth = 1;
|
||||||
|
|
||||||
|
void _setOperator(FilterOperator op) =>
|
||||||
|
onChanged(group.copyWith(operator: op));
|
||||||
|
|
||||||
|
void _addLeaf() {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: '',
|
||||||
|
);
|
||||||
|
onChanged(group.copyWith(children: [...group.children, leaf]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addSubGroup() {
|
||||||
|
final sub = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [],
|
||||||
|
);
|
||||||
|
onChanged(group.copyWith(children: [...group.children, sub]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceChild(int index, FilterNode node) {
|
||||||
|
final next = List<FilterNode>.from(group.children);
|
||||||
|
next[index] = node;
|
||||||
|
onChanged(group.copyWith(children: next));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeChild(int index) {
|
||||||
|
final next = List<FilterNode>.from(group.children)..removeAt(index);
|
||||||
|
onChanged(group.copyWith(children: next));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isRoot = depth == 0;
|
||||||
|
final content = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_OperatorRow(
|
||||||
|
operator: group.operator,
|
||||||
|
onChanged: _setOperator,
|
||||||
|
onRemove: onRemoveGroup,
|
||||||
|
),
|
||||||
|
for (var i = 0; i < group.children.length; i++) _buildChild(context, i),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _addLeaf,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add condition'),
|
||||||
|
),
|
||||||
|
if (depth < _maxDepth)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _addSubGroup,
|
||||||
|
icon: const Icon(Icons.playlist_add, size: 16),
|
||||||
|
label: const Text('Add group'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (isRoot) return content;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChild(BuildContext context, int i) {
|
||||||
|
final child = group.children[i];
|
||||||
|
return switch (child) {
|
||||||
|
final FilterLeaf leaf => _LeafRow(
|
||||||
|
key: ValueKey(i),
|
||||||
|
leaf: leaf,
|
||||||
|
onChanged: (l) => _replaceChild(i, l),
|
||||||
|
onDelete: () => _removeChild(i),
|
||||||
|
),
|
||||||
|
final FilterGroup sub => _GroupEditor(
|
||||||
|
key: ValueKey(i),
|
||||||
|
group: sub,
|
||||||
|
onChanged: (g) => _replaceChild(i, g),
|
||||||
|
depth: depth + 1,
|
||||||
|
onRemoveGroup: () => _removeChild(i),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Operator row (AND / OR toggle)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _OperatorRow extends StatelessWidget {
|
||||||
|
const _OperatorRow({
|
||||||
|
required this.operator,
|
||||||
|
required this.onChanged,
|
||||||
|
this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterOperator operator;
|
||||||
|
final void Function(FilterOperator) onChanged;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SegmentedButton<FilterOperator>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: FilterOperator.and_, label: Text('AND')),
|
||||||
|
ButtonSegment(value: FilterOperator.or_, label: Text('OR')),
|
||||||
|
],
|
||||||
|
selected: {operator},
|
||||||
|
onSelectionChanged: (s) => onChanged(s.first),
|
||||||
|
style: const ButtonStyle(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (onRemove != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
tooltip: 'Remove group',
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Leaf row (field | comparison | value | delete)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _LeafRow extends StatefulWidget {
|
||||||
|
const _LeafRow({
|
||||||
|
super.key,
|
||||||
|
required this.leaf,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FilterLeaf leaf;
|
||||||
|
final void Function(FilterLeaf) onChanged;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_LeafRow> createState() => _LeafRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LeafRowState extends State<_LeafRow> {
|
||||||
|
late final TextEditingController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = TextEditingController(text: widget.leaf.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_LeafRow old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (widget.leaf.value != _ctrl.text) {
|
||||||
|
_ctrl.text = widget.leaf.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFieldChanged(FilterField? f) {
|
||||||
|
if (f == null) return;
|
||||||
|
final allowed = f.allowedComparisons;
|
||||||
|
final comp = allowed.contains(widget.leaf.comparison)
|
||||||
|
? widget.leaf.comparison
|
||||||
|
: allowed.first;
|
||||||
|
widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCompChanged(FilterComparison? c) {
|
||||||
|
if (c == null) return;
|
||||||
|
widget.onChanged(widget.leaf.copyWith(comparison: c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
DropdownButton<FilterField>(
|
||||||
|
value: widget.leaf.field,
|
||||||
|
onChanged: _onFieldChanged,
|
||||||
|
isDense: true,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
items: FilterField.values
|
||||||
|
.map(
|
||||||
|
(f) => DropdownMenuItem(value: f, child: Text(f.label)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
DropdownButton<FilterComparison>(
|
||||||
|
value: widget.leaf.comparison,
|
||||||
|
onChanged: _onCompChanged,
|
||||||
|
isDense: true,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
items: widget.leaf.field.allowedComparisons
|
||||||
|
.map(
|
||||||
|
(c) => DropdownMenuItem(value: c, child: Text(c.label)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
onChanged: (v) =>
|
||||||
|
widget.onChanged(widget.leaf.copyWith(value: v)),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'value',
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
contentPadding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
|||||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
COMPONENT Runtime)
|
COMPONENT Runtime)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||||
|
COMPONENT Runtime)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
|
|||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
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
|
// 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.
|
// window content area (1280×800) to FlView, not the default 1×1.
|
||||||
gtk_widget_show_all(GTK_WIDGET(window));
|
gtk_widget_show_all(GTK_WIDGET(window));
|
||||||
|
|||||||
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 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").
|
|
||||||
@@ -371,6 +371,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -562,6 +570,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -659,10 +675,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1088,26 +1104,26 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.31.0"
|
version: "1.30.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.17"
|
version: "0.6.16"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
|
|
||||||
# Local persistence (offline-first)
|
# Local persistence (offline-first)
|
||||||
drift: ^2.20.3
|
drift: ^2.20.3
|
||||||
|
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
|
||||||
sqlite3_flutter_libs: ^0.6.0+eol
|
sqlite3_flutter_libs: ^0.6.0+eol
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
@@ -78,9 +79,17 @@ dev_dependencies:
|
|||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
fake_async: ^1.3.1
|
fake_async: ^1.3.1
|
||||||
path_provider_platform_interface: ^2.1.2
|
path_provider_platform_interface: ^2.1.2
|
||||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
|
||||||
url_launcher_platform_interface: ^2.3.2
|
url_launcher_platform_interface: ^2.3.2
|
||||||
plugin_platform_interface: ^2.1.8
|
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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customManagers": [
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.fvmrc$"],
|
||||||
|
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
|
||||||
|
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
|
||||||
|
"datasourceTemplate": "docker",
|
||||||
|
"versioningTemplate": "semver"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/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"
|
||||||
@@ -7,7 +7,7 @@ ROOT=$(git rev-parse --show-toplevel)
|
|||||||
FILE="$ROOT/ci/main.go"
|
FILE="$ROOT/ci/main.go"
|
||||||
|
|
||||||
# Static images from From("...") literals in ci/main.go
|
# Static images from From("...") literals in ci/main.go
|
||||||
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
|
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
|
||||||
|
|
||||||
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
||||||
FVMRC="$ROOT/.fvmrc"
|
FVMRC="$ROOT/.fvmrc"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const _noCode = {
|
|||||||
'lib/core/repositories/user_preferences_repository.dart',
|
'lib/core/repositories/user_preferences_repository.dart',
|
||||||
'lib/core/models/undo_action.dart',
|
'lib/core/models/undo_action.dart',
|
||||||
'lib/core/models/user_preferences.dart',
|
'lib/core/models/user_preferences.dart',
|
||||||
|
'lib/core/models/note.dart',
|
||||||
|
'lib/core/repositories/note_repository.dart',
|
||||||
'lib/core/storage/secure_storage.dart',
|
'lib/core/storage/secure_storage.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ const _excluded = {
|
|||||||
'lib/ui/screens/sieve_scripts_screen.dart',
|
'lib/ui/screens/sieve_scripts_screen.dart',
|
||||||
'lib/ui/screens/sync_log_screen.dart',
|
'lib/ui/screens/sync_log_screen.dart',
|
||||||
'lib/ui/screens/thread_detail_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/screens/undo_log_screen.dart',
|
||||||
'lib/ui/widgets/folder_drawer.dart',
|
'lib/ui/widgets/folder_drawer.dart',
|
||||||
'lib/ui/widgets/secure_email_webview.dart',
|
'lib/ui/widgets/secure_email_webview.dart',
|
||||||
@@ -81,6 +84,11 @@ const _excluded = {
|
|||||||
'lib/data/repositories/user_preferences_repository_impl.dart',
|
'lib/data/repositories/user_preferences_repository_impl.dart',
|
||||||
'lib/ui/screens/user_preferences_screen.dart',
|
'lib/ui/screens/user_preferences_screen.dart',
|
||||||
'lib/core/services/update_service.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/filter_builder.dart',
|
||||||
|
'lib/ui/widgets/thread_tile.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ _filter_noise() {
|
|||||||
_run() {
|
_run() {
|
||||||
: > "$OUT" ; : > "$RC_FILE"
|
: > "$OUT" ; : > "$RC_FILE"
|
||||||
{
|
{
|
||||||
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
|
||||||
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
|
||||||
--project-id "$FIREBASE_PROJECT_ID"
|
--project-id "$FIREBASE_PROJECT_ID"
|
||||||
echo $? > "$RC_FILE"
|
echo $? > "$RC_FILE"
|
||||||
@@ -44,6 +44,10 @@ _run() {
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
_run && break
|
_run && break
|
||||||
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
|
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
|
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
|
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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
|
if [ -z "${SOPS_AGE_KEY:-}" ]; then
|
||||||
echo "Error: SOPS_AGE_KEY must be set."
|
echo "Error: SOPS_AGE_KEY must be set."
|
||||||
@@ -16,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
|||||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$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
|
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||||
# can use them without referencing Forgejo secrets directly.
|
# can use them without referencing Forgejo secrets directly.
|
||||||
export_secret() {
|
export_secret() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local value
|
local value
|
||||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
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
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
# Use heredoc syntax for multiline-safe export.
|
# Use heredoc syntax for multiline-safe export.
|
||||||
# Avoid adding a second trailing newline for values that already end with one
|
# Avoid adding a second trailing newline for values that already end with one
|
||||||
@@ -50,16 +64,28 @@ export_secret "RENOVATE_FORGEJO_TOKEN"
|
|||||||
# Setup SSH directory and keys
|
# Setup SSH directory and keys
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
chmod 700 ~/.ssh
|
chmod 700 ~/.ssh
|
||||||
|
rm -f ~/.ssh/dagger_key
|
||||||
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
|
||||||
chmod 600 ~/.ssh/dagger_key
|
chmod 600 ~/.ssh/dagger_key
|
||||||
|
|
||||||
# Add remote host to known_hosts
|
# Add remote host to known_hosts
|
||||||
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
_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
|
||||||
|
|
||||||
# Create a background SSH tunnel to the Dagger engine.
|
# Create a background SSH tunnel to the Dagger engine Unix socket.
|
||||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host,
|
||||||
|
# eliminating the need for a socat bridge on the server side.
|
||||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||||
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
_t0=$SECONDS
|
||||||
|
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
|
||||||
|
_elapsed=$(( SECONDS - _t0 ))
|
||||||
|
if [ "$_elapsed" -gt 10 ]; then
|
||||||
|
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||||
|
fi
|
||||||
|
|
||||||
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
|
||||||
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module sharedinbox.de/bugreport
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
@@ -2,8 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,7 +11,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -21,12 +18,10 @@ import (
|
|||||||
// BugReport represents the data stored in report.json
|
// BugReport represents the data stored in report.json
|
||||||
type BugReport struct {
|
type BugReport struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Email string `json:"email"`
|
|
||||||
AboutInfo string `json:"about_info"`
|
AboutInfo string `json:"about_info"`
|
||||||
EmailData string `json:"email_data,omitempty"`
|
EmailData string `json:"email_data,omitempty"`
|
||||||
SyncLog string `json:"sync_log,omitempty"`
|
SyncLog string `json:"sync_log,omitempty"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
HashedIP string `json:"hashed_ip"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -75,12 +70,6 @@ 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
|
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 {
|
func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Enable CORS so the web app (if applicable) can upload
|
// Enable CORS so the web app (if applicable) can upload
|
||||||
@@ -143,20 +132,6 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
|||||||
emailData := r.FormValue("email_data")
|
emailData := r.FormValue("email_data")
|
||||||
syncLog := r.FormValue("sync_log")
|
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()
|
uuidVal, err := generateUUID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to generate UUID: %v", err)
|
log.Printf("Failed to generate UUID: %v", err)
|
||||||
@@ -179,12 +154,10 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
|||||||
// Write report.json
|
// Write report.json
|
||||||
report := BugReport{
|
report := BugReport{
|
||||||
Description: description,
|
Description: description,
|
||||||
Email: email,
|
|
||||||
AboutInfo: aboutInfo,
|
AboutInfo: aboutInfo,
|
||||||
EmailData: emailData,
|
EmailData: emailData,
|
||||||
SyncLog: syncLog,
|
SyncLog: syncLog,
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
HashedIP: hashedIP,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reportJSONPath := filepath.Join(reportDir, "report.json")
|
reportJSONPath := filepath.Join(reportDir, "report.json")
|
||||||
@@ -205,6 +178,17 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
|||||||
return
|
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
|
// Save attachments
|
||||||
form := r.MultipartForm
|
form := r.MultipartForm
|
||||||
files := form.File["attachments[]"]
|
files := form.File["attachments[]"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# Run inside nix develop:
|
# Run inside nix develop:
|
||||||
# stalwart-dev/integration_android_test.sh
|
# stalwart-dev/integration_android_test.sh
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||||
|
|
||||||
_SCRIPT_START=$(date +%s%3N)
|
_SCRIPT_START=$(date +%s%3N)
|
||||||
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
|
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#
|
#
|
||||||
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
|
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
|
||||||
set -Eeuo pipefail
|
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.
|
# Timing helper: prints elapsed seconds since script start with a label.
|
||||||
_SCRIPT_START=$(date +%s%3N)
|
_SCRIPT_START=$(date +%s%3N)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Starts Stalwart in the background on fresh random ports, runs Flutter
|
# Starts Stalwart in the background on fresh random ports, runs Flutter
|
||||||
# integration tests, then stops it.
|
# integration tests, then stops it.
|
||||||
set -Eeuo pipefail
|
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
|
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
|
||||||
|
|
||||||
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
|
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -169,6 +170,15 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
totalCount: 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 {
|
class _FakeEmails implements EmailRepository {
|
||||||
@@ -263,6 +273,13 @@ class _FakeEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
@Tags(['nightly'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
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',
|
||||||
|
timeout: Timeout.none, () 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}');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -421,6 +421,7 @@ void main() {
|
|||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
||||||
expect(results, hasLength(1));
|
expect(results, hasLength(1));
|
||||||
@@ -432,6 +433,7 @@ void main() {
|
|||||||
|
|
||||||
final r = makeRepo();
|
final r = makeRepo();
|
||||||
await r.accounts.addAccount(account, userPass);
|
await r.accounts.addAccount(account, userPass);
|
||||||
|
await r.emails.syncEmails('test', 'INBOX');
|
||||||
|
|
||||||
final results = await r.emails.searchEmails(
|
final results = await r.emails.searchEmails(
|
||||||
'test',
|
'test',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/services.dart' show MissingPluginException;
|
import 'package:flutter/services.dart' show MissingPluginException;
|
||||||
import 'package:mockito/annotations.dart';
|
import 'package:mockito/annotations.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -137,6 +138,12 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
@override
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||||
@override
|
@override
|
||||||
Future<List<EmailAddress>> searchAddresses(
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
@@ -239,6 +246,15 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
|||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
totalCount: 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 {
|
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'dart:async' as _i5;
|
|||||||
|
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i7;
|
import 'package:mockito/src/dummies.dart' as _i7;
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i10;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
||||||
@@ -235,6 +236,31 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
) as _i5.Future<_i2.Mailbox>);
|
) 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].
|
/// A class which mocks [EmailRepository].
|
||||||
@@ -520,6 +546,22 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||||
) as _i5.Future<List<_i3.Email>>);
|
) as _i5.Future<List<_i3.Email>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i5.Future<List<_i3.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
_i10.FilterGroup? filter,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#searchEmailsStructured,
|
||||||
|
[
|
||||||
|
accountId,
|
||||||
|
filter,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||||
|
) as _i5.Future<List<_i3.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
|
|||||||
@@ -453,6 +453,191 @@ void main() {
|
|||||||
expect(results.first.subject, 'foobar baz');
|
expect(results.first.subject, 'foobar baz');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searchEmails filters by mailboxPath using local FTS5', () async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
// Insert matching email in INBOX.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Meeting agenda'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Insert matching email in a different mailbox — must not appear.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Sent',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Meeting follow-up'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||||
|
expect(results, hasLength(1));
|
||||||
|
expect(results.first.subject, 'Meeting agenda');
|
||||||
|
expect(results.first.mailboxPath, 'INBOX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmailsGlobal includes emails matched by note text', () async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
// Email whose subject does NOT match — but its note does.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
messageId: const Value('<msg1@example.com>'),
|
||||||
|
subject: const Value('Weekly report'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Add a note referencing the email's messageId.
|
||||||
|
await r.db.into(r.db.emailNotes).insert(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: 'note-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<msg1@example.com>',
|
||||||
|
noteText: 'Urgent follow-up needed',
|
||||||
|
serverId: '42',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
|
||||||
|
expect(results, hasLength(1));
|
||||||
|
expect(results.first.subject, 'Weekly report');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmails includes emails matched by note text in mailbox',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
messageId: const Value('<msg1@example.com>'),
|
||||||
|
subject: const Value('Project update'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Email in a different mailbox — its note must not appear in INBOX search.
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Sent',
|
||||||
|
uid: 2,
|
||||||
|
messageId: const Value('<msg2@example.com>'),
|
||||||
|
subject: const Value('Other email'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emailNotes).insert(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: 'note-1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<msg1@example.com>',
|
||||||
|
noteText: 'remember to call client',
|
||||||
|
serverId: '42',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emailNotes).insert(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: 'note-2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<msg2@example.com>',
|
||||||
|
noteText: 'remember to call client',
|
||||||
|
serverId: '43',
|
||||||
|
createdAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
|
||||||
|
expect(results, hasLength(1));
|
||||||
|
expect(results.first.subject, 'Project update');
|
||||||
|
expect(results.first.mailboxPath, 'INBOX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmailsGlobal returns results sorted by receivedAt descending',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Older report'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Newer report'),
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmailsGlobal(null, 'report');
|
||||||
|
expect(results, hasLength(2));
|
||||||
|
expect(results[0].subject, 'Newer report');
|
||||||
|
expect(results[1].subject, 'Older report');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchEmails returns results sorted by receivedAt descending',
|
||||||
|
() async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:1',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 1,
|
||||||
|
subject: const Value('Older meeting'),
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:2',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 2,
|
||||||
|
subject: const Value('Newer meeting'),
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||||
|
expect(results, hasLength(2));
|
||||||
|
expect(results[0].subject, 'Newer meeting');
|
||||||
|
expect(results[1].subject, 'Older meeting');
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'searchAddresses returns results sorted by most recently used',
|
'searchAddresses returns results sorted by most recently used',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
|
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('FilterGroup', () {
|
||||||
|
test('empty() creates an empty group', () {
|
||||||
|
final g = FilterGroup.empty();
|
||||||
|
expect(g.isEmpty, isTrue);
|
||||||
|
expect(g.children, isEmpty);
|
||||||
|
expect(g.operator, FilterOperator.and_);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-empty group is not isEmpty', () {
|
||||||
|
final g = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'test',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(g.isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith changes operator', () {
|
||||||
|
final g = FilterGroup.empty().copyWith(operator: FilterOperator.or_);
|
||||||
|
expect(g.operator, FilterOperator.or_);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith changes children', () {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'hello',
|
||||||
|
);
|
||||||
|
final g = FilterGroup.empty().copyWith(children: [leaf]);
|
||||||
|
expect(g.children, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FilterLeaf', () {
|
||||||
|
test('copyWith changes field', () {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'x',
|
||||||
|
);
|
||||||
|
final updated = leaf.copyWith(field: FilterField.to);
|
||||||
|
expect(updated.field, FilterField.to);
|
||||||
|
expect(updated.comparison, FilterComparison.contains);
|
||||||
|
expect(updated.value, 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith changes value', () {
|
||||||
|
final leaf = FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.is_,
|
||||||
|
value: 'old',
|
||||||
|
);
|
||||||
|
final updated = leaf.copyWith(value: 'new');
|
||||||
|
expect(updated.value, 'new');
|
||||||
|
expect(updated.field, FilterField.subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size field allows over/under comparisons', () {
|
||||||
|
expect(
|
||||||
|
FilterField.size.allowedComparisons,
|
||||||
|
containsAll([FilterComparison.over, FilterComparison.under]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('address fields do not allow over/under', () {
|
||||||
|
for (final f in [FilterField.from_, FilterField.to, FilterField.cc]) {
|
||||||
|
expect(f.allowedComparisons, isNot(contains(FilterComparison.over)));
|
||||||
|
expect(f.allowedComparisons, isNot(contains(FilterComparison.under)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SieveSerializer', () {
|
||||||
|
final ser = SieveSerializer();
|
||||||
|
|
||||||
|
test('empty filter with keep action', () {
|
||||||
|
final script = ser.serialize(FilterGroup.empty(), [KeepAction()]);
|
||||||
|
expect(script, contains('keep;'));
|
||||||
|
expect(script, isNot(contains('if ')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single from-contains condition', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'alice',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [FileIntoAction('Work')]);
|
||||||
|
expect(script, contains('require'));
|
||||||
|
expect(script, contains('fileinto'));
|
||||||
|
expect(script, contains('"Work"'));
|
||||||
|
expect(script, contains(':contains'));
|
||||||
|
expect(script, contains('"from"'));
|
||||||
|
expect(script, contains('"alice"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AND group serialises as allof', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'invoice',
|
||||||
|
),
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'supplier',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [KeepAction()]);
|
||||||
|
expect(script, contains('allof'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OR group serialises as anyof', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.or_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'a',
|
||||||
|
),
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'b',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [DiscardAction()]);
|
||||||
|
expect(script, contains('anyof'));
|
||||||
|
expect(script, contains('discard;'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size over condition', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.size,
|
||||||
|
comparison: FilterComparison.over,
|
||||||
|
value: '1000000',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [DiscardAction()]);
|
||||||
|
expect(script, contains('size :over 1000000'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mark-as-seen action emits setflag', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'newsletter',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [MarkAsSeenAction()]);
|
||||||
|
expect(script, contains('setflag'));
|
||||||
|
expect(script, contains(r'\Seen'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes quotes in values', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'say "hello"',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final script = ser.serialize(group, [KeepAction()]);
|
||||||
|
expect(script, contains(r'say \"hello\"'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FilterSieveConverter', () {
|
||||||
|
final conv = FilterSieveConverter();
|
||||||
|
|
||||||
|
test('returns null for empty script', () {
|
||||||
|
expect(conv.parse(''), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses simple address test', () {
|
||||||
|
const script = '''
|
||||||
|
if address :contains "from" "alice@example.com" {
|
||||||
|
keep;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.children, hasLength(1));
|
||||||
|
final leaf = result.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.field, FilterField.from_);
|
||||||
|
expect(leaf.comparison, FilterComparison.contains);
|
||||||
|
expect(leaf.value, 'alice@example.com');
|
||||||
|
expect(result.actions, hasLength(1));
|
||||||
|
expect(result.actions.first, isA<KeepAction>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses subject header test', () {
|
||||||
|
const script = '''
|
||||||
|
if header :is "subject" "Hello" {
|
||||||
|
fileinto "Inbox";
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
final leaf = result!.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.field, FilterField.subject);
|
||||||
|
expect(leaf.comparison, FilterComparison.is_);
|
||||||
|
expect(leaf.value, 'Hello');
|
||||||
|
final action = result.actions.first as FileIntoAction;
|
||||||
|
expect(action.folder, 'Inbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses allof group as AND', () {
|
||||||
|
const script = '''
|
||||||
|
if allof(
|
||||||
|
address :contains "from" "alice",
|
||||||
|
header :contains "subject" "invoice"
|
||||||
|
) {
|
||||||
|
keep;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.operator, FilterOperator.and_);
|
||||||
|
expect(result.group.children, hasLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses anyof group as OR', () {
|
||||||
|
const script = '''
|
||||||
|
if anyof(
|
||||||
|
address :contains "from" "a",
|
||||||
|
address :contains "from" "b"
|
||||||
|
) {
|
||||||
|
discard;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.operator, FilterOperator.or_);
|
||||||
|
expect(result.actions.first, isA<DiscardAction>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses size over test', () {
|
||||||
|
const script = '''
|
||||||
|
if size :over 500000 {
|
||||||
|
discard;
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
final leaf = result!.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.field, FilterField.size);
|
||||||
|
expect(leaf.comparison, FilterComparison.over);
|
||||||
|
expect(leaf.value, '500000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses setflag \\\\Seen as MarkAsSeenAction', () {
|
||||||
|
const script = r'''
|
||||||
|
if header :contains "subject" "newsletter" {
|
||||||
|
setflag "\\Seen";
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.actions.first, isA<MarkAsSeenAction>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for unsupported test', () {
|
||||||
|
const script = '''
|
||||||
|
if exists "X-Custom-Header" {
|
||||||
|
keep;
|
||||||
|
}''';
|
||||||
|
expect(conv.parse(script), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips through serializer', () {
|
||||||
|
final group = FilterGroup(
|
||||||
|
operator: FilterOperator.and_,
|
||||||
|
children: [
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.from_,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'alice@example.com',
|
||||||
|
),
|
||||||
|
FilterLeaf(
|
||||||
|
field: FilterField.subject,
|
||||||
|
comparison: FilterComparison.contains,
|
||||||
|
value: 'invoice',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final actions = <SieveAction>[FileIntoAction('Work')];
|
||||||
|
final script = SieveSerializer().serialize(group, actions);
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
expect(result!.group.operator, FilterOperator.and_);
|
||||||
|
expect(result.group.children, hasLength(2));
|
||||||
|
expect(result.actions, hasLength(1));
|
||||||
|
expect((result.actions.first as FileIntoAction).folder, 'Work');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses require block and ignores it', () {
|
||||||
|
const script = '''
|
||||||
|
require ["fileinto"];
|
||||||
|
if address :contains "from" "bob" {
|
||||||
|
fileinto "Archive";
|
||||||
|
}''';
|
||||||
|
final result = conv.parse(script);
|
||||||
|
expect(result, isNotNull);
|
||||||
|
final leaf = result!.group.children.first as FilterLeaf;
|
||||||
|
expect(leaf.value, 'bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 38);
|
expect(db.schemaVersion, 40);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,12 +424,18 @@ void main() {
|
|||||||
expect(userPrefsColumns, contains('prefetch_mode'));
|
expect(userPrefsColumns, contains('prefetch_mode'));
|
||||||
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
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();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 38', () async {
|
test('fresh install creates all tables at schemaVersion 40', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -458,6 +464,8 @@ void main() {
|
|||||||
'local_sieve_applied', // v32
|
'local_sieve_applied', // v32
|
||||||
'user_preferences', // v34
|
'user_preferences', // v34
|
||||||
'image_trusted_senders', // v37
|
'image_trusted_senders', // v37
|
||||||
|
'email_notes', // v39
|
||||||
|
'installed_versions', // v40
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -493,7 +501,49 @@ void main() {
|
|||||||
expect(userPrefsColumns, contains('prefetch_mode'));
|
expect(userPrefsColumns, contains('prefetch_mode'));
|
||||||
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
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();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/508:
|
||||||
|
// _openConnection's setup callback must not crash when PRAGMA journal_mode =
|
||||||
|
// WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5)
|
||||||
|
// because a WorkManager background task already has the DB open in WAL mode.
|
||||||
|
group('WAL setup (#508)', () {
|
||||||
|
test(
|
||||||
|
'setupPragmasForTesting does not throw when WAL is already active and '
|
||||||
|
'another connection holds an open read transaction',
|
||||||
|
() {
|
||||||
|
final dbFile = File('test_wal_busy_508.db');
|
||||||
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
addTearDown(() {
|
||||||
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// conn1: enable WAL and keep a read transaction open — simulates a
|
||||||
|
// WorkManager background task that opened the DB before the foreground
|
||||||
|
// app starts.
|
||||||
|
final conn1 = sqlite.sqlite3.open(dbFile.path);
|
||||||
|
conn1.execute('PRAGMA journal_mode = WAL;');
|
||||||
|
conn1.execute('BEGIN;');
|
||||||
|
conn1.select('SELECT 1;');
|
||||||
|
|
||||||
|
// conn2: run the exact production setup through setupPragmasForTesting.
|
||||||
|
// This must not throw even though conn1 holds an open transaction and
|
||||||
|
// the DB is already in WAL mode.
|
||||||
|
final conn2 = sqlite.sqlite3.open(dbFile.path);
|
||||||
|
expect(() => setupPragmasForTesting(conn2), returnsNormally);
|
||||||
|
|
||||||
|
conn1.execute('ROLLBACK;');
|
||||||
|
conn1.close();
|
||||||
|
conn2.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// checked the _running flag (only true after start() is called).
|
// checked the _running flag (only true after start() is called).
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -77,6 +78,15 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
totalCount: 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 {
|
class _FakeEmails implements EmailRepository {
|
||||||
@@ -135,6 +145,12 @@ class _FakeEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
@override
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
||||||
@override
|
@override
|
||||||
Future<List<EmailAddress>> searchAddresses(
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:fake_async/fake_async.dart';
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
@@ -67,6 +68,15 @@ class _FakeMailboxes implements MailboxRepository {
|
|||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
totalCount: 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 {
|
class _CountingEmails implements EmailRepository {
|
||||||
@@ -131,6 +141,12 @@ class _CountingEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||||
@override
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? a,
|
||||||
|
FilterGroup f,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
||||||
@override
|
@override
|
||||||
Future<List<EmailAddress>> searchAddresses(
|
Future<List<EmailAddress>> searchAddresses(
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import 'dart:async' as _i4;
|
|||||||
|
|
||||||
import 'package:mockito/mockito.dart' as _i1;
|
import 'package:mockito/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i5;
|
import 'package:mockito/src/dummies.dart' as _i5;
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i6;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
|
import 'package:sharedinbox/core/models/undo_action.dart' as _i8;
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
|
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
|
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: avoid_redundant_argument_values
|
// ignore_for_file: avoid_redundant_argument_values
|
||||||
@@ -342,6 +343,22 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
) as _i4.Future<List<_i2.Email>>);
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<List<_i2.Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
_i6.FilterGroup? filter,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#searchEmailsStructured,
|
||||||
|
[
|
||||||
|
accountId,
|
||||||
|
filter,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||||
|
) as _i4.Future<List<_i2.Email>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
@@ -558,13 +575,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
/// A class which mocks [UndoRepository].
|
/// A class which mocks [UndoRepository].
|
||||||
///
|
///
|
||||||
/// See the documentation for Mockito's code generation for more information.
|
/// See the documentation for Mockito's code generation for more information.
|
||||||
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
|
class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
|
||||||
MockUndoRepository() {
|
MockUndoRepository() {
|
||||||
_i1.throwOnMissingStub(this);
|
_i1.throwOnMissingStub(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
|
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#saveAction,
|
#saveAction,
|
||||||
[action],
|
[action],
|
||||||
@@ -584,15 +601,15 @@ class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
|
|||||||
) as _i4.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
|
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#getHistory,
|
#getHistory,
|
||||||
[],
|
[],
|
||||||
{#limit: limit},
|
{#limit: limit},
|
||||||
),
|
),
|
||||||
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
|
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
|
||||||
) as _i4.Future<List<_i7.UndoAction>>);
|
) as _i4.Future<List<_i8.UndoAction>>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> clearHistory() => (super.noSuchMethod(
|
_i4.Future<void> clearHistory() => (super.noSuchMethod(
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
|
|||||||
FakeAccountRepository(accounts),
|
FakeAccountRepository(accounts),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: AboutScreen()),
|
child: MaterialApp(
|
||||||
|
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||||
|
home: const AboutScreen(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
|
||||||
class _FakeAssetBundle extends CachingAssetBundle {
|
class _FakeAssetBundle extends CachingAssetBundle {
|
||||||
@@ -19,16 +23,33 @@ 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 =
|
const _fakeChangelog =
|
||||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
DefaultAssetBundle(
|
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
|
||||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
@@ -41,14 +62,58 @@ void main() {
|
|||||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(_buildScreen(assets: {}));
|
||||||
DefaultAssetBundle(
|
|
||||||
bundle: _FakeAssetBundle({}),
|
|
||||||
child: const MaterialApp(home: ChangeLogScreen()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
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},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The link text "#42" must be visible in the rendered output.
|
||||||
|
expect(find.textContaining('#42'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -102,30 +104,6 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping search icon shows search bar', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
buildApp(
|
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
||||||
overrides: [
|
|
||||||
accountRepositoryProvider.overrideWithValue(
|
|
||||||
FakeAccountRepository([kTestAccount]),
|
|
||||||
),
|
|
||||||
mailboxRepositoryProvider.overrideWithValue(
|
|
||||||
FakeMailboxRepository(),
|
|
||||||
),
|
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.search));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
|
||||||
expect(find.text('Search…'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('submitting a search query shows "No results" when empty', (
|
testWidgets('submitting a search query shows "No results" when empty', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -430,6 +408,230 @@ void main() {
|
|||||||
expect(find.text('Result email'), findsWidgets);
|
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(
|
||||||
|
'folder search returns results from local cache without any network call',
|
||||||
|
(tester) async {
|
||||||
|
// Verifies that searchEmails is backed by local SQLite (not IMAP).
|
||||||
|
// The repository throws if a network call is attempted, yet search
|
||||||
|
// must still return results.
|
||||||
|
final email = testEmail(subject: 'Cached subject');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
onSearch: (_) async {
|
||||||
|
// Local DB: return cached results immediately.
|
||||||
|
return [email];
|
||||||
|
},
|
||||||
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Cached');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Cached subject'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('deleting all search results pops back to previous screen', (
|
testWidgets('deleting all search results pops back to previous screen', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/discovery_result.dart';
|
import 'package:sharedinbox/core/models/discovery_result.dart';
|
||||||
import 'package:sharedinbox/core/models/draft.dart';
|
import 'package:sharedinbox/core/models/draft.dart';
|
||||||
@@ -192,6 +193,20 @@ class FakeMailboxRepository implements MailboxRepository {
|
|||||||
_mailboxes.add(mailbox);
|
_mailboxes.add(mailbox);
|
||||||
return 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 {
|
class FakeEmailRepository implements EmailRepository {
|
||||||
@@ -202,12 +217,17 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
|
|
||||||
final List<Email> _searchResults;
|
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({
|
FakeEmailRepository({
|
||||||
List<Email>? emails,
|
List<Email>? emails,
|
||||||
Email? emailDetail,
|
Email? emailDetail,
|
||||||
EmailBody? emailBody,
|
EmailBody? emailBody,
|
||||||
List<Email>? searchResults,
|
List<Email>? searchResults,
|
||||||
String rawRfc822 = '',
|
String rawRfc822 = '',
|
||||||
|
this.onSearch,
|
||||||
}) : _emails = emails ?? [],
|
}) : _emails = emails ?? [],
|
||||||
_emailDetail = emailDetail,
|
_emailDetail = emailDetail,
|
||||||
_searchResults = searchResults ?? [],
|
_searchResults = searchResults ?? [],
|
||||||
@@ -260,7 +280,15 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||||
@@ -326,8 +354,10 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async =>
|
) async {
|
||||||
_searchResults;
|
if (onSearch != null) return onSearch!(query);
|
||||||
|
return _searchResults;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(
|
Future<List<Email>> searchEmailsGlobal(
|
||||||
@@ -336,6 +366,13 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
) async =>
|
) async =>
|
||||||
_searchResults;
|
_searchResults;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Email>> searchEmailsStructured(
|
||||||
|
String? accountId,
|
||||||
|
FilterGroup filter,
|
||||||
|
) async =>
|
||||||
|
[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> getEmailsByAddress(
|
Future<List<Email>> getEmailsByAddress(
|
||||||
String? accountId,
|
String? accountId,
|
||||||
@@ -551,6 +588,7 @@ Widget buildApp({
|
|||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
@@ -558,6 +596,7 @@ Widget buildApp({
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# 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`.
|
|
||||||