Compare commits

..
Author SHA1 Message Date
agentloop fc5954ab1a plan: refresh plan for issue #533 2026-06-08 04:55:55 +00:00
Bot of Thomas Güttler 13a0c99f57 test(search): cover sort order of searchEmailsStructured and getEmailsByAddress (#534) 2026-06-07 20:24:25 +02:00
Bot of Thomas Güttlerandguettli 41c8196a97 feat(detail): drop AppBar subject, surface Mark as spam icon (#531)
## Summary
- Drop the truncated subject preview from the single-mail AppBar title; the full subject is already shown in the body header.
- Replace the popup-menu entry for **Mark as spam** with a direct `IconButton` (`Icons.report_outlined`) in the AppBar actions so the action is reachable without opening the `⋯` menu.
- Update affected widget tests for the new layout (subject is only in the body header; spam action is now a standalone button rather than a popup item).

Closes #528

## Test plan
- [x] `dart format --output=none --set-exit-if-changed lib test` — 0 changed
- [x] `dart analyze --fatal-infos lib test` — no issues
- [x] `flutter test test/widget/email_detail_screen_test.dart test/widget/email_list_screen_test.dart` — 42/42 passing
- [x] Full widget suite (`flutter test test/widget/`) — 172/172 passing

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/531
2026-06-07 20:05:57 +02:00
Bot of Thomas Güttler 38f7ada8b5 chore(deps): bump go_router, file_picker, flutter_local_notifications (#532) 2026-06-07 19:45:22 +02:00
Bot of Thomas Güttler a227f8607c fix(ci): use endpoints that exist in Forgejo for wait-time + LAST_DEPLOYED_SHA (#529) 2026-06-07 14:02:01 +02:00
Bot of Thomas Güttler 5db5d957ab fix(ci): use /actions/runs endpoint in remaining wait-time steps (#524) 2026-06-07 06:59:00 +02:00
Bot of Thomas Güttler 0dd1d7232b fix(ci): use /actions/runs endpoint in deploy.yml wait-time steps (#522) 2026-06-07 06:33:57 +02:00
Bot of Thomas Güttler 282a64b4c3 fix: include mailboxPath in IMAP email ID to prevent UID collisions (#511) 2026-06-07 05:30:59 +02:00
Bot of Thomas Güttler 8e26715658 ci: eliminate duplicate build_runner run in CheckGenerated (#514) 2026-06-07 05:30:43 +02:00
Bot of Thomas Güttler e4cc92867e ci(website): add change detection to skip unconditional hourly deploys (#515) 2026-06-07 05:04:58 +02:00
15 changed files with 769 additions and 164 deletions
+6 -6
View File
@@ -19,14 +19,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+43 -59
View File
@@ -21,14 +21,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -51,43 +51,27 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent workflow run where deploy-playstore actually succeeded
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
# every run and the fallback diff to only cover HEAD~1..HEAD.
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# (per-job records) directly and filter for the task we care about. Filtering at the
# task level also distinguishes runs where the Play Store job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
base_api = f"{server}/api/v1/repos/{repo}/actions"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("status") == "success"
]
# Walk runs newest-first; pick the first one where deploy-playstore
# actually ran (conclusion=success), not just skipped.
for run in runs:
run_id = run.get("id")
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Deploy to Play Store" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
for t in data.get("workflow_runs", []):
if (t.get("workflow_id") == "deploy.yml"
and t.get("name") == "Build & Deploy to Play Store"
and t.get("status") == "success"):
print(t.get("head_sha") or "")
sys.exit(0)
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
@@ -164,14 +148,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -215,14 +199,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -260,14 +244,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -310,14 +294,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+12 -12
View File
@@ -20,14 +20,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
@@ -73,14 +73,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+99 -6
View File
@@ -12,10 +12,103 @@ on:
workflow_dispatch:
jobs:
check-changes:
name: Detect Website Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect website changes since last deploy
id: diff
shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: |
# On push or workflow_dispatch always deploy
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent successful "Build & Update Website" task. Forgejo's API
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# (per-job records) directly and filter for the task we care about. Filtering at the
# task level also distinguishes runs where the deploy job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read())
for t in data.get("workflow_runs", []):
if (t.get("workflow_id") == "website.yml"
and t.get("name") == "Build & Update Website"
and t.get("status") == "success"):
print(t.get("head_sha") or "")
sys.exit(0)
print("")
except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
print("")
PYEOF
)
if [ -z "$LAST_DEPLOYED_SHA" ]; then
echo "::warning::Could not determine last successfully deployed SHA — deploying as a precaution"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "::notice::Website deploy SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff from last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit.
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying as a precaution"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changed files:"
echo "$CHANGED"
website_re='^(website/|scripts/website-verify\.sh|\.forgejo/workflows/website\.yml)'
if echo "$CHANGED" | grep -qE "$website_re"; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
echo "::notice::Website deploy TRIGGERED — website-relevant files changed since $LAST_DEPLOYED_SHA"
else
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "::notice::Website deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no website-relevant changes"
fi
deploy:
name: Build & Update Website
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- name: Print runner wait time
@@ -24,14 +117,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }}
run: |
runner_start=$(date +%s)
created_at=$(curl -sf \
created=$(curl -sf --max-time 30 \
-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)
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created" ]; then
queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)"
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else
echo "Runner wait time: unknown (API lookup failed)"
fi
+101
View File
@@ -0,0 +1,101 @@
# Plan: Consolidate Email-List UI
## Goal
Three list surfaces — folder view (`EmailListScreen`), combined inbox (`CombinedInboxScreen`), and search results (in `SearchScreen` plus the in-screen search in `EmailListScreen`, plus `AddressEmailsScreen`) — currently duplicate selection state, swipe-dismiss handling, batch actions (archive/delete/spam/move/snooze), and tile rendering. Unify into one widget so behaviour and UI are identical everywhere. Thread detail view is intentionally out of scope.
## Current duplication
| Concern | `EmailListScreen` | `CombinedInboxScreen` | `SearchScreen` / `AddressEmailsScreen` |
| --- | --- | --- | --- |
| Tile widget | `EmailThreadTile` + `ThreadTile` (search) | `EmailThreadTile` | `ThreadTile` / inline `ListTile` |
| Selection set | thread + per-email | thread only | none |
| Selection AppBar/BottomBar | full set of 5 actions | archive + delete only | n/a |
| Swipe dismiss | archive/delete + undo | archive/delete + undo (copy) | none |
| Batch actions | archive, delete, spam, move, snooze | archive, delete (re-implemented) | none |
Tile widgets `lib/ui/widgets/email_thread_tile.dart` and `lib/ui/widgets/thread_tile.dart` render the same fields in slightly different layouts.
## Target architecture
### 1. New widget `lib/ui/widgets/email_thread_list.dart`
A self-contained `ConsumerStatefulWidget` that owns selection state and renders the list. API:
```dart
EmailThreadList({
required Stream<List<EmailThread>> threads, // folder + combined inbox
// or:
required List<EmailThread> staticThreads, // search / address results
required EmailListContext listContext, // see below
bool showAccountLabel = false, // combined inbox
bool showLocationLabel = false, // search / cross-mailbox
bool enableSwipe = true,
bool enablePagination = true,
List<EmailBatchAction> actions = EmailBatchAction.standard,
ValueChanged<EmailThread>? onTap, // null → default navigation
})
```
`EmailListContext` carries `accountId?` (nullable for combined/global views) and `mailboxPath?` (nullable for combined/global views). Batch actions read these to scope role lookups and undo-action source paths; when null they fall back to per-thread `t.accountId` / `t.mailboxPath` (this is how `CombinedInboxScreen._batchArchive` already groups by account).
Encapsulated:
- `_selectedThreadIds` plus toggle/clear/select-all helpers.
- `_currentThreads` (last stream emission).
- `_limit` pagination with `Load more`.
- Selection-mode `AppBar` and `BottomAppBar` rendering — driven by the host scaffold via two builders the widget exposes (`buildSelectionAppBar`, `buildSelectionBottomBar`) so the host keeps Scaffold ownership but doesn't reimplement them.
- Swipe-to-archive / swipe-to-delete + undo push.
### 2. Shared action layer `lib/ui/screens/email_action_helpers.dart`
Extend the existing file with the batch ops currently duplicated:
```dart
enum EmailBatchAction { archive, delete, markSpam, move, snooze }
Future<void> batchMoveToRole(WidgetRef ref, BuildContext ctx, {
required List<EmailThread> threads,
required String role,
required String dialogTitle,
required String createFolderName,
});
Future<void> batchDelete(WidgetRef ref, {required List<EmailThread> threads});
Future<void> batchMove(WidgetRef ref, BuildContext ctx, {required List<EmailThread> threads});
Future<void> batchSnooze(WidgetRef ref, BuildContext ctx, {required List<EmailThread> threads});
Future<void> swipeDismiss(WidgetRef ref, EmailThread thread, DismissDirection dir);
```
Each function fetches `originalEmails`, runs the repo calls, and pushes a single `UndoAction`. Grouping by `accountId` lives here so combined-inbox-style multi-account selections work for every action (not only archive/delete). `_batchMoveToRole` from `EmailListScreen` and `_batchArchive` from `CombinedInboxScreen` collapse into one function.
### 3. Unify the tile widgets
Keep `ThreadTile` (`lib/ui/widgets/thread_tile.dart`) as the single tile. Move the `Dismissible` wrapper out — `EmailThreadList` owns swipe — and add the optional `showAccount` subtitle currently in `EmailThreadTile`. Delete `lib/ui/widgets/email_thread_tile.dart`.
## Screen refactors
- `combined_inbox_screen.dart` — drop selection state, swipe handler, batch methods, `_buildThreadList`, `_selectionBottomBar`. Replace body with `EmailThreadList(stream: emailRepo.observeAllInboxThreads(...), listContext: const EmailListContext.allAccounts(), showAccountLabel: true)`. AppBar/drawer/FAB stay.
- `email_list_screen.dart` — keep search-bar, sync banner, folder drawer, `Mark all as read`. Replace `_buildStreamBody` and `_buildEmailList` with `EmailThreadList`. Drop selection state, `_toggleThreadSelection`, `_selectionBottomBar`, `_batch*` methods, `_onSwipeDismissed`. The search path inside this screen (results from `searchEmails`) becomes `EmailThreadList(staticThreads: results.map(EmailThread.fromEmail).toList(), enableSwipe: false)` — the per-email vs per-thread split goes away once everything is treated as a thread of one.
- `search_screen.dart` — Messages section uses `EmailThreadList(staticThreads: ..., enableSwipe: false, showLocationLabel: true, actions: EmailBatchAction.standard)`, so global search results now support the same selection + batch actions. Folders and Addresses sections unchanged.
- `address_emails_screen.dart` — replace inline `ListView.builder` with `EmailThreadList`, gaining selection/swipe/batch parity.
## Migration steps
1. Add `EmailThreadList` widget with selection, swipe, pagination, and AppBar/BottomBar builders. Lift the existing logic verbatim from `EmailListScreen` so behaviour is unchanged.
2. Promote the five batch ops + swipe handler into `email_action_helpers.dart`; switch `EmailListScreen` to call them. Keep tile tests passing.
3. Fold `showAccount` and `Dismissible`-out into `ThreadTile`; delete `EmailThreadTile`; update imports.
4. Migrate `CombinedInboxScreen` to `EmailThreadList`. Combined inbox now supports spam/move/snooze (was missing). Verify multi-account batches still group correctly.
5. Migrate the search-result branch inside `EmailListScreen`, then the Messages section of `SearchScreen`, then `AddressEmailsScreen`.
6. Run `flutter analyze` and the integration tests under `integration_test/` (folder, combined inbox, and search exercise the same code path now, so a single regression test set covers all surfaces).
## Out of scope
- `ThreadDetailScreen` — single-thread message list, intentionally different.
- Repository / DB code in `lib/core/repositories/` — no changes; the unification is purely on the UI layer.
- Folder drawer, sync banner, search bar — remain owned by their hosting screens.
## Risks / open questions
- Combined inbox currently has no `mailboxPath` per selection — confirmed handled by grouping selected threads by `accountId` then looking up archive/junk/etc. per group. The same grouping must work for the move/snooze sheet (the destination picker needs an account; when multiple accounts are selected, prompt per-account or block — recommend block with a SnackBar to mirror existing per-folder constraints; flag for user feedback).
- Snooze for cross-account selection: same constraint as above — implementation should iterate accounts.
- Swipe-dismiss in search results: currently disabled in `SearchScreen` and `AddressEmailsScreen`. Plan keeps `enableSwipe: false` for those to avoid disorienting users when a swiped item disappears from a filtered list; revisit if user wants parity.
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 40;
const int dbSchemaVersion = 41;
+110
View File
@@ -679,6 +679,116 @@ class AppDatabase extends _$AppDatabase {
if (from < 40) {
await m.createTable(installedVersions);
}
if (from < 41) {
// Fix IMAP email IDs to include mailboxPath, preventing UID
// collisions across mailboxes (IMAP UIDs are mailbox-scoped).
// New format: "accountId:mailboxPath:uid" (was "accountId:uid").
//
// defer_foreign_keys defers the email_bodies→emails FK check
// to COMMIT so the two tables can be updated sequentially inside
// the migration transaction without a transient FK violation.
await customStatement('PRAGMA defer_foreign_keys = ON');
// 1. Remap email_bodies.email_id before emails.id changes.
await customStatement('''
UPDATE email_bodies
SET email_id = (
SELECT e.account_id || ':' || e.mailbox_path || ':' || CAST(e.uid AS TEXT)
FROM emails e
JOIN accounts a ON a.id = e.account_id
WHERE e.id = email_bodies.email_id
AND a.account_type = 'imap'
)
WHERE EXISTS (
SELECT 1 FROM emails e
JOIN accounts a ON a.id = e.account_id
WHERE e.id = email_bodies.email_id
AND a.account_type = 'imap'
)
''');
// 2. Update emails.thread_id where it was set to the email's own
// id (fallback for messages with no Message-ID header).
await customStatement('''
UPDATE emails
SET thread_id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
AND thread_id = id
''');
// 3. Update the primary key on emails.
await customStatement('''
UPDATE emails
SET id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
WHERE account_id IN (
SELECT id FROM accounts WHERE account_type = 'imap'
)
''');
// 5. Rebuild threads for IMAP accounts from the updated email rows.
// The threads table stores denormalised data (latest_email_id,
// email_ids_json) that references email IDs, so it is simpler to
// delete and reconstruct than to patch the JSON in SQL.
await customStatement('''
DELETE FROM threads
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
''');
final imapAccounts = await (select(accounts)
..where((t) => t.accountType.equals('imap')))
.get();
for (final acct in imapAccounts) {
final emailRows = await (select(emails)
..where((t) => t.accountId.equals(acct.id)))
.get();
final groups = <String, List<Email>>{};
for (final row in emailRows) {
final key = '${row.mailboxPath}:${row.threadId ?? row.id}';
groups.putIfAbsent(key, () => []).add(row);
}
for (final threadEmails in groups.values) {
threadEmails.sort((a, b) {
final da = a.sentAt ?? a.receivedAt;
final db = b.sentAt ?? b.receivedAt;
return da.compareTo(db);
});
final latest = threadEmails.last;
final seen = <String>{};
final participants = <Map<String, dynamic>>[];
for (final e in threadEmails) {
final from = jsonDecode(e.fromJson) as List<dynamic>;
for (final a in from.cast<Map<String, dynamic>>()) {
final email = a['email'] as String;
if (seen.add(email)) {
participants.add({'name': a['name'], 'email': email});
}
}
}
await into(threads).insert(
ThreadsCompanion.insert(
id: latest.threadId ?? latest.id,
accountId: latest.accountId,
mailboxPath: latest.mailboxPath,
subject: Value(latest.subject),
latestDate: latest.sentAt ?? latest.receivedAt,
messageCount: Value(threadEmails.length),
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
participantsJson: Value(jsonEncode(participants)),
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
),
);
}
}
}
},
);
@@ -561,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
for (final msg in result.messages) {
final uid = msg.uid;
if (uid == null) continue;
final emailId = '${account.id}:$uid';
final emailId = '${account.id}:$mailboxPath:$uid';
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
EmailsCompanion(
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
@@ -616,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
continue;
}
bytes += msg.size ?? 0;
final emailId = '${account.id}:$uid';
final emailId = '${account.id}:$mailboxPath:$uid';
final msgId = envelope.messageId?.trim();
final inReplyTo = envelope.inReplyTo?.trim();
final refs = msg.getHeaderValue('References')?.trim();
+9 -7
View File
@@ -74,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !isMobile,
title: Text(
header?.subject ?? '(loading…)',
overflow: TextOverflow.ellipsis,
),
actions: [
IconButton(
icon: const Icon(Icons.reply),
@@ -133,12 +129,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
PopupMenuButton<String>(
itemBuilder: (ctx) => [
const PopupMenuItem(value: 'forward', child: Text('Forward')),
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
@@ -166,8 +170,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
+59 -51
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "99.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "12.1.0"
archive:
dependency: transitive
description:
@@ -165,10 +165,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.1"
code_builder:
dependency: transitive
description:
@@ -237,18 +237,18 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
url: "https://pub.dev"
source: hosted
version: "3.1.7"
version: "3.1.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.14"
device_info_plus:
dependency: "direct main"
description:
@@ -349,10 +349,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
url: "https://pub.dev"
source: hosted
version: "12.0.0-beta.4"
version: "12.0.0-beta.5"
fixnum:
dependency: transitive
description:
@@ -391,34 +391,42 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
url: "https://pub.dev"
source: hosted
version: "21.0.0"
version: "22.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
version: "8.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
url: "https://pub.dev"
source: hosted
version: "11.0.0"
version: "12.0.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.1.0"
flutter_markdown_plus:
dependency: "direct main"
description:
@@ -431,10 +439,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
@@ -447,10 +455,10 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
@@ -526,10 +534,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
url: "https://pub.dev"
source: hosted
version: "17.2.3"
version: "17.3.0"
graphs:
dependency: transitive
description:
@@ -542,10 +550,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.2"
http:
dependency: "direct main"
description:
@@ -675,10 +683,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: "direct main"
description:
@@ -707,10 +715,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.17.6"
version: "0.19.1"
node_preamble:
dependency: transitive
description:
@@ -723,10 +731,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
open_filex:
dependency: "direct main"
description:
@@ -1013,13 +1021,13 @@ packages:
source: hosted
version: "1.10.2"
sqlite3:
dependency: "direct dev"
dependency: "direct main"
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.3.2"
sqlite3_flutter_libs:
dependency: "direct main"
description:
@@ -1088,10 +1096,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
version: "3.4.1"
term_glyph:
dependency: transitive
description:
@@ -1104,26 +1112,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
@@ -1288,10 +1296,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
url: "https://pub.dev"
source: hosted
version: "4.12.0"
version: "4.13.0"
webview_flutter_platform_interface:
dependency: transitive
description:
@@ -1304,10 +1312,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
url: "https://pub.dev"
source: hosted
version: "3.25.1"
version: "3.26.0"
win32:
dependency: transitive
description:
@@ -1381,5 +1389,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+3 -3
View File
@@ -28,7 +28,7 @@ dependencies:
flutter_riverpod: ^3.0.0
# Navigation
go_router: ^17.2.3
go_router: ^17.3.0
# Secure credential storage (passwords)
flutter_secure_storage: ^10.0.0
@@ -37,7 +37,7 @@ dependencies:
intl: ^0.20.2
# File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4
file_picker: ^12.0.0-beta.5
open_filex: ^4.6.0
mime: ^2.0.0
@@ -56,7 +56,7 @@ dependencies:
flutter_markdown_plus: ^1.0.7
# Background sync and local notifications
flutter_local_notifications: ^21.0.0
flutter_local_notifications: ^22.0.0
workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
+130
View File
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/data/db/database.dart' hide Account;
@@ -262,6 +263,50 @@ void main() {
expect(emails.map((e) => e.uid).toList(), [3, 2, 1]);
});
test('same UID in different mailboxes yields independent emails', () async {
// Regression test for the UID collision bug: IMAP UIDs are mailbox-scoped,
// so UID 50 in INBOX and UID 50 in Archive must get distinct local IDs.
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// New ID format: accountId:mailboxPath:uid
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:INBOX:50',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 50,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:Archive:50',
accountId: 'acc-1',
mailboxPath: 'Archive',
uid: 50,
receivedAt: DateTime(2024, 1, 2),
),
);
final inboxEmail = await r.emails.getEmail('acc-1:INBOX:50');
expect(inboxEmail, isNotNull);
expect(inboxEmail!.mailboxPath, 'INBOX');
final archiveEmail = await r.emails.getEmail('acc-1:Archive:50');
expect(archiveEmail, isNotNull);
expect(archiveEmail!.mailboxPath, 'Archive');
final inboxEmails = await r.emails.observeEmails('acc-1', 'INBOX').first;
expect(inboxEmails, hasLength(1));
expect(inboxEmails.first.id, 'acc-1:INBOX:50');
final archiveEmails =
await r.emails.observeEmails('acc-1', 'Archive').first;
expect(archiveEmails, hasLength(1));
expect(archiveEmails.first.id, 'acc-1:Archive:50');
});
test('syncEmails propagates IMAP error', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
@@ -638,6 +683,91 @@ void main() {
expect(results[1].subject, 'Older meeting');
});
test(
'searchEmailsStructured 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 invoice'),
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 invoice'),
receivedAt: DateTime(2024, 6),
),
);
final filter = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
],
);
final results = await r.emails.searchEmailsStructured(null, filter);
expect(results, hasLength(2));
expect(results[0].subject, 'Newer invoice');
expect(results[1].subject, 'Older invoice');
},
);
test(
'getEmailsByAddress 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 hello'),
receivedAt: DateTime(2024),
fromJson: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
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 hello'),
receivedAt: DateTime(2024, 6),
fromJson: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
final results =
await r.emails.getEmailsByAddress(null, 'bob@example.com');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer hello');
expect(results[1].subject, 'Older hello');
},
);
test(
'searchAddresses returns results sorted by most recently used',
() async {
+179 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 40);
expect(db.schemaVersion, 41);
await db.close();
});
@@ -435,7 +435,184 @@ void main() {
},
);
test('fresh install creates all tables at schemaVersion 40', () async {
test('v40→v41: IMAP email IDs gain mailboxPath segment', () async {
final dbFile = File('test_migration_v40.db');
if (dbFile.existsSync()) dbFile.deleteSync();
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
imap_host TEXT NOT NULL DEFAULT '',
imap_port INTEGER NOT NULL DEFAULT 993,
imap_ssl INTEGER NOT NULL DEFAULT 1,
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_ssl INTEGER NOT NULL DEFAULT 1,
account_type TEXT NOT NULL DEFAULT 'imap',
jmap_url TEXT NULL,
username TEXT NOT NULL DEFAULT '',
verbose INTEGER NOT NULL DEFAULT 0,
manage_sieve_host TEXT NOT NULL DEFAULT '',
manage_sieve_port INTEGER NOT NULL DEFAULT 4190,
manage_sieve_ssl INTEGER NOT NULL DEFAULT 1,
manage_sieve_available INTEGER NULL
)
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
mailbox_path TEXT NOT NULL,
uid INTEGER NOT NULL,
subject TEXT NULL,
sent_at INTEGER NULL,
received_at INTEGER NOT NULL,
from_json TEXT NOT NULL DEFAULT '[]',
to_addresses TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
is_seen INTEGER NOT NULL DEFAULT 0,
is_flagged INTEGER NOT NULL DEFAULT 0,
has_attachment INTEGER NOT NULL DEFAULT 0,
thread_id TEXT NULL,
message_id TEXT NULL,
in_reply_to TEXT NULL,
"references" TEXT NULL,
snoozed_until INTEGER NULL,
snoozed_from_mailbox_path TEXT NULL,
list_unsubscribe_header TEXT NULL
)
''');
rawDb.execute('''
CREATE TABLE email_bodies (
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails (id) ON DELETE CASCADE,
text_body TEXT NULL,
html_body TEXT NULL,
attachments_json TEXT NOT NULL DEFAULT '[]',
cached_at INTEGER NULL,
headers_json TEXT NULL,
mime_tree_json TEXT NULL
)
''');
rawDb.execute('''
CREATE TABLE threads (
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
mailbox_path TEXT NOT NULL,
id TEXT NOT NULL,
subject TEXT NULL,
latest_date INTEGER NOT NULL,
message_count INTEGER NOT NULL DEFAULT 1,
has_unread INTEGER NOT NULL DEFAULT 0,
is_flagged INTEGER NOT NULL DEFAULT 0,
participants_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
latest_email_id TEXT NOT NULL,
email_ids_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (account_id, mailbox_path, id)
)
''');
rawDb.execute('''
CREATE TABLE pending_changes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
change_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NULL
)
''');
// Insert an IMAP account.
rawDb.execute(
"INSERT INTO accounts (id, display_name, email) VALUES ('acc-1', 'Alice', 'alice@example.com')",
);
// Two emails with the same UID but in different mailboxes — old format.
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
rawDb.execute(
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
"VALUES ('acc-1:50', 'acc-1', 'INBOX', 50, $now, 'acc-1:50')",
);
rawDb.execute(
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at) '
"VALUES ('acc-1:50-arch', 'acc-1', 'Archive', 50, $now)",
);
// A third email with a Message-ID-based thread_id (should not be changed).
rawDb.execute(
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
"VALUES ('acc-1:99', 'acc-1', 'INBOX', 99, $now, '<original@example.com>')",
);
// Email body for the first email.
rawDb.execute(
"INSERT INTO email_bodies (email_id, text_body) VALUES ('acc-1:50', 'body text')",
);
// Thread for the first email (old-format IDs).
rawDb.execute(
'INSERT INTO threads (account_id, mailbox_path, id, latest_date, latest_email_id, email_ids_json) '
"VALUES ('acc-1', 'INBOX', 'acc-1:50', $now, 'acc-1:50', '[\"acc-1:50\"]')",
);
// A pending change referencing the first email's old ID.
rawDb.execute(
'INSERT INTO pending_changes (account_id, resource_type, resource_id, change_type, payload, created_at) '
"VALUES ('acc-1', 'Email', 'acc-1:50', 'flag_seen', '{\"seen\":true}', $now)",
);
rawDb.execute('PRAGMA user_version = 40');
rawDb.close();
// Open with Drift to trigger the migration.
final db = AppDatabase(NativeDatabase(dbFile));
await db.select(db.accounts).get();
// emails.id should now use the accountId:mailboxPath:uid format.
final emailRows = await db.select(db.emails).get();
final emailIds = emailRows.map((r) => r.id).toSet();
expect(emailIds, contains('acc-1:INBOX:50'));
expect(emailIds, contains('acc-1:Archive:50'));
expect(emailIds, contains('acc-1:INBOX:99'));
// Old-format IDs must be gone.
expect(emailIds, isNot(contains('acc-1:50')));
expect(emailIds, isNot(contains('acc-1:99')));
// email_bodies.email_id must be updated.
final bodyRows = await db.select(db.emailBodies).get();
expect(bodyRows, hasLength(1));
expect(bodyRows.first.emailId, 'acc-1:INBOX:50');
// thread_id where it was the email's own ID should be updated.
final inboxEmail = emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:50');
expect(inboxEmail.threadId, 'acc-1:INBOX:50');
// thread_id based on a real Message-ID must be left unchanged.
final inboxEmail99 =
emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:99');
expect(inboxEmail99.threadId, '<original@example.com>');
// threads must be rebuilt with new-format IDs.
final threadRows = await db.select(db.threads).get();
final thread = threadRows.firstWhere((t) => t.mailboxPath == 'INBOX');
expect(thread.latestEmailId, 'acc-1:INBOX:50');
expect(thread.emailIdsJson, contains('acc-1:INBOX:50'));
// pending_changes.resource_id is not updated by the migration
// (IMAP operations use payload uid/mailboxPath, so this is safe).
final changeRows = await db.select(db.pendingChanges).get();
expect(changeRows, hasLength(1));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 41', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
+13 -13
View File
@@ -81,7 +81,7 @@ void main() {
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows subject in app bar after data loads', (tester) async {
testWidgets('shows subject in email header section', (tester) async {
final email = testEmail(subject: 'Project update');
const body = EmailBody(
emailId: 'acc-1:42',
@@ -106,8 +106,8 @@ void main() {
);
await tester.pumpAndSettle();
// Subject appears in both the app bar and the email header section.
expect(find.text('Project update'), findsAtLeastNWidgets(1));
// Subject appears only in the email header section, not in the app bar.
expect(find.text('Project update'), findsOneWidget);
expect(find.text('See attached slides.'), findsOneWidget);
});
@@ -266,7 +266,7 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam is in popup menu, not a standalone button', (
testWidgets('Mark as spam is a standalone button, not in popup menu', (
tester,
) async {
await tester.pumpWidget(
@@ -279,19 +279,19 @@ void main() {
);
await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
// Standalone icon button for mark as spam is in the app bar.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsNothing,
findsOneWidget,
);
// It appears in the popup menu.
// It does NOT appear in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
expect(find.text('Mark as spam'), findsNothing);
});
testWidgets('Mark as spam shows dialog when no junk folder', (
@@ -309,11 +309,11 @@ void main() {
);
await tester.pumpAndSettle();
// Open the popup menu first, then tap Mark as spam.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as spam'));
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
);
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
+2 -2
View File
@@ -446,10 +446,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
// The detail body header shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
of: find.byType(EmailDetailScreen),
matching: find.text('Alpha Match'),
),
findsOneWidget,