Compare commits

..
Author SHA1 Message Date
agentloopandClaude Opus 4.7 29bc403180 test(imap-move): satisfy require_trailing_commas in COPYUID-less test
`dart analyze --fatal-infos` (used by CI but not by my local
`flutter analyze` invocation) flagged the inline `searchResults`
literal as missing a trailing comma. Move the literal to a
`const` map with a trailing comma so the lint passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:51:39 +00:00
agentloopandClaude Opus 4.7 0141d86361 fix(imap): remap local id to new UID after MOVE so caches survive
IMAP UIDs are mailbox-scoped, so MOVE assigns a fresh UID in the
destination folder. The flush previously discarded the response
from `client.uidMove(...)`, so the local row kept the *source*
UID while its `mailbox_path` already pointed at the destination.
Two things broke:

- Deletion reconciliation, which runs per mailbox and compares
  local UIDs to the server's `ALL` search result, would not find
  the source UID in the destination mailbox and wipe the row —
  taking the cached body and queued undo with it.
- `UndoLog` rows kept referencing the old `accountId:mailbox:uid`
  id, so undo had to fall back to a Message-ID lookup just to
  rediscover the moved message.

The fix captures the RFC 4315 `COPYUID` response code that
modern `UIDPLUS` servers attach to `MOVE`/`COPY` (already exposed
as `GenericImapResult.responseCodeCopyUid` in `enough_mail`).
When that's missing — i.e. the server doesn't support UIDPLUS —
we fall back to `UID SEARCH HEADER Message-ID …` in the
destination mailbox. Either way the local id is rewritten in
place to `accountId:destMailbox:newUid` and the cascading
`email_bodies`, `threads`, `pending_changes`, and `undo_actions`
references are updated in the same transaction.

`_reconcileDeletedImap` now also skips rows whose
`move`/`snooze`/`unsnooze` is still queued in `pending_changes`,
so the optimistic local move can't be wiped between the
optimistic write and the server flush.

Closes #539

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 13:19:04 +00:00
Bot of Thomas Güttlerandguettli f1f7de7b4d feat(undo-log): hyperlink email rows in Undo Log Detail (#474) (#547)
## Summary

- Each email row in the **Undo Log Detail** "Emails" section is now tappable.
- Tapping resolves the email via `EmailRepository.findEmailByMessageId(accountId, messageId)` and navigates to its **current** location, so the link survives the move/snooze that changed its IMAP UID.
- If the email has no Message-ID, or no row matches the lookup (e.g. hard-deleted), a SnackBar explains the situation instead of navigating.

A `chevron_right` trailing icon was added to signal the rows are now navigable.

Closes #474

## Test plan

- [x] New widget test `test/widget/undo_log_detail_screen_test.dart` covers:
  - tap on a row whose lookup hits → navigates to `/accounts/<acc>/mailboxes/<encoded>/emails/<encoded>` with the **current** mailbox/id
  - tap when lookup returns `null` → "Email no longer exists" SnackBar, no navigation
  - tap when the original row has no Message-ID → "no Message-ID" SnackBar, no navigation

Co-authored-by: guettli <guettli@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/547
2026-06-10 13:15:48 +02:00
de2b9d22b4 fix(ci): stop gradle daemon between flutter build apk and assembleAndroidTest (#554)
## Summary

The Firebase Test Lab job (issue #549) failed because `flutter build apk --debug --no-pub` spawned a Gradle daemon, whose journal-cache lock file was left on the persistent Dagger `gradle-cache` mount after the `WithExec` container was torn down. The next exec, `./gradlew --no-daemon app:assembleAndroidTest`, then timed out after 60s waiting for that stale lock:

```
> Timeout waiting to lock journal cache (/home/ci/.gradle/caches/journal-1). It is currently in use by another process.
  Owner PID: 88
  Our PID: 53
```

The pre-existing `--no-daemon` only prevented stale daemon-registry reuse, not stale lock files.

**Fix:** chain `./gradlew --stop` into the first `WithExec` so the daemon shuts down gracefully and releases its locks before Dagger snapshots the layer.

## Test plan

- [ ] CI passes
- [ ] Manually re-run the Firebase Tests workflow (`workflow_dispatch`) and confirm the Gradle journal-lock error no longer appears

Closes #549

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Till Düßmann (Claude agent) <tilldu@googlemail.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/554
2026-06-10 13:13:28 +02:00
0297701829 ci: automate dev container build via devcontainer.json + workflow (#553)
Closes #552

## Summary
- Add `.devcontainer/devcontainer.json` pointing at `../Dockerfile.dev` so VS Code / Codespaces / any devcontainer-aware tool can build the dev environment directly from source.
- Add `.forgejo/workflows/publish-dev-container.yml` that rebuilds `Dockerfile.dev` and pushes it to `codeberg.org/guettli/sharedinbox-dev` whenever `Dockerfile.dev`, the devcontainer config, or the workflow itself changes on `main`. The image is tagged both `:latest` and with the short commit SHA for pinnable references.
- The workflow uses the built-in `FORGEJO_TOKEN` to log in to Codeberg's container registry — no extra secrets required.

## Notes
- No existing references to `ghcr.io/guettli/sharedinbox-dev` were found in the repo, so issue step 3 (updating image references) is a no-op here.
- `workflow_dispatch` is also enabled so the image can be rebuilt manually if needed.

## Verification
- `python3 -c "import json; json.load(...)"` parses the devcontainer config.
- `python3 -c "import yaml; yaml.safe_load(...)"` parses the workflow.
- Triggers (paths filter) match the source files the issue identifies as drift risks.

Co-authored-by: Thomas Güttler <tilldu@googlemail.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/553
2026-06-09 21:31:45 +02:00
ee238b85c7 fix(ci): set loop/code label on Firebase test failure issues (#551)
Closes #550

## Summary

When Firebase instrumented tests fail in the nightly run, the workflow opens a tracking issue. It currently tags it with the legacy `Ready` label, which is not part of the current agent loop. Switch the label to `loop/code` so the coding agent picks it up automatically and the error gets fixed.

## Change

- `.forgejo/workflows/firebase-tests.yml`: set `loop/code` instead of `Ready` on the created failure issue.

## Test plan

- [ ] Wait for next scheduled (or manually dispatched) Firebase test failure and confirm the created issue carries the `loop/code` label.

Co-authored-by: guettlibot <tilldu@googlemail.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/551
2026-06-09 16:08:19 +02:00
Thomas Güttler f0eff7dc7c Merge branch 'drop-nix' 2026-06-08 22:44:55 +02:00
8ea5237991 fix(detail): auto-dismiss "Load remote images" snack bar (#548)
## Summary

- The "Load remote images" snack bar in single-mail view (and the analogous thread view) never disappeared on its own — the user had to interact with it.
- Flutter's `SnackBar` defaults to `persist: true` whenever an `action` is provided (see `flutter/lib/src/material/snack_bar.dart`: `persist = persist ?? action != null`), which short-circuits the duration-based dismiss timer in `ScaffoldMessengerState.build`:

  ```dart
  _snackBarTimer = Timer(snackBar.duration, () {
    if (snackBar.persist) return;          // <-- here
    hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
  });
  ```

  So the explicit `duration: 3s` was set, but the "View" action made the snack bar persistent and the timer's callback returned early.
- Pass `persist: false` explicitly on both snack bars so the 3-second timer fires and the snack bar slides away on its own, while the "View" action button still works to navigate to the trusted-senders settings.

## Test plan

- [x] Added widget regression test in `test/widget/email_detail_screen_test.dart` (`Load remote images snack bar auto-dismisses after 3 seconds`).
- [x] Added analogous test in `test/widget/thread_detail_screen_test.dart`.
- [x] `task test-widget` — all 174 widget tests pass.
- [x] `scripts/run_unit_tests.sh` — all 552 unit tests pass.
- [x] `fvm dart analyze --fatal-infos` on changed files — no issues.
- [x] `fvm dart format` — no diffs.
- [ ] Manual: open a single mail with HTML body from an untrusted sender; tap "Load remote images"; verify the snack bar appears, images load, and the snack bar disappears after ~3 seconds while the "View" action button still navigates to `/accounts/trusted-senders` when tapped.

Closes #484

Co-authored-by: Agentloop Bot <agentloop-bot@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/548
2026-06-08 21:59:49 +02:00
17 changed files with 884 additions and 11 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"name": "SharedInbox Dev",
"build": {
"dockerfile": "../Dockerfile.dev",
"context": ".."
},
"workspaceFolder": "/src",
"workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached",
"remoteUser": "ci"
}
+1 -1
View File
@@ -135,7 +135,7 @@ jobs:
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
@@ -0,0 +1,44 @@
name: Publish Dev Container
on:
push:
branches: [main]
paths:
- 'Dockerfile.dev'
- '.devcontainer/devcontainer.json'
- '.forgejo/workflows/publish-dev-container.yml'
workflow_dispatch:
jobs:
publish:
name: Build & Push sharedinbox-dev
runs-on: ubuntu-latest
timeout-minutes: 30
env:
REGISTRY: codeberg.org
IMAGE: codeberg.org/guettli/sharedinbox-dev
steps:
- uses: actions/checkout@v4
- name: Log in to Codeberg container registry
env:
FORGEJO_TOKEN: ${{ github.token }}
run: |
echo "$FORGEJO_TOKEN" \
| docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin
- name: Build image
run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
docker build \
-t "$IMAGE:latest" \
-t "$IMAGE:$SHORT_SHA" \
-f Dockerfile.dev \
.
- name: Push image
run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
docker push "$IMAGE:latest"
docker push "$IMAGE:$SHORT_SHA"
+7
View File
@@ -54,6 +54,13 @@ This document covers the mail-to-database sync layer only, not the UI.
optimistic local update; `flushPendingChanges` drains the queue over a single
IMAP connection at the start of each sync cycle.
- Sent messages are appended to the Sent folder after SMTP delivery.
- IMAP move remap: after a `MOVE` is flushed, the local row id is rewritten in
place using the RFC 4315 `COPYUID` response code (UIDPLUS); if the server
doesn't support UIDPLUS, the new UID is looked up via `UID SEARCH HEADER
Message-ID …` in the destination mailbox. Cached bodies (`email_bodies`),
threads, queued pending changes, and undo entries follow the new id.
Deletion reconciliation skips rows whose `move`/`snooze`/`unsnooze` is still
in `pending_changes` so the optimistic local move isn't wiped mid-flight.
- Sync retries use exponential backoff after failures.
### Cross-protocol
+11
View File
@@ -81,6 +81,17 @@ start()
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
folder is re-scanned and the checkpoint is reset.
**IMAP move remap** — IMAP UIDs are mailbox-scoped, so a moved message gets a new UID in
its destination folder. When a `move`/`snooze`/`unsnooze` change is flushed, the local row
id (`accountId:mailboxPath:uid`) is rewritten in place to point at the new UID. The new
UID is taken from the RFC 4315 `COPYUID` response code returned by `MOVE`; if the server
does not advertise `UIDPLUS`, a `UID SEARCH HEADER Message-ID …` in the destination
mailbox is used as a fallback. `email_bodies`, `threads`, `pending_changes`, and
`undo_actions` rows that reference the old id are updated atomically so cached bodies and
pending undo operations keep tracking the same physical message. Deletion reconciliation
also skips rows whose move is still queued, so the optimistic local move never gets
wiped mid-flight.
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
+8 -1
View File
@@ -814,7 +814,14 @@ func (m *Ci) DeployApk(
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.firebaseBase().
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
// `flutter build apk` spawns a Gradle daemon. When this WithExec ends the
// container is torn down and the daemon is killed, but its journal-cache
// lock file on the persistent gradle-cache volume keeps its dead PID — the
// next gradlew invocation then times out waiting for that lock. `gradlew
// --stop` shuts the daemon down gracefully so the lock is released before
// Dagger snapshots the layer.
WithExec([]string{"/bin/bash", "-c",
`flutter build apk --debug --no-pub && (cd android && ./gradlew --stop)`}).
WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was
// preserved in the Dagger layer snapshot but whose process no longer exists.
@@ -6,6 +6,7 @@ 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:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -728,6 +729,14 @@ class EmailRepositoryImpl implements EmailRepository {
await _saveSyncState(accountId, resourceType, jsonEncode(data));
}
@visibleForTesting
Future<void> reconcileDeletedImapForTest(
String accountId,
String mailboxPath,
List<int> serverUids,
) =>
_reconcileDeletedImap(accountId, mailboxPath, serverUids);
Future<void> _reconcileDeletedImap(
String accountId,
String mailboxPath,
@@ -752,10 +761,28 @@ class EmailRepositoryImpl implements EmailRepository {
return;
}
// Email IDs that still have a queued move/snooze/unsnooze waiting to be
// flushed. The optimistic local move has already updated mailbox_path, so
// these rows look orphaned from both the old and new mailbox until the
// server applies the change and we remap to the destination UID. Skipping
// them here avoids wiping the row mid-flight.
final inFlightIds = await (_db.selectOnly(_db.pendingChanges)
..addColumns([_db.pendingChanges.resourceId])
..where(
_db.pendingChanges.accountId.equals(accountId) &
_db.pendingChanges.changeType.isIn(
const ['move', 'snooze', 'unsnooze'],
),
))
.map((row) => row.read(_db.pendingChanges.resourceId)!)
.get();
final inFlightSet = inFlightIds.toSet();
final serverUidSet = serverUids.toSet();
final affectedThreads = <String>{};
for (final row in localRows) {
if (!serverUidSet.contains(row.uid)) {
if (inFlightSet.contains(row.id)) continue;
affectedThreads.add(row.threadId ?? row.id);
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
}
@@ -2317,7 +2344,15 @@ class EmailRepositoryImpl implements EmailRepository {
? await client.uidMarkFlagged(seq)
: await client.uidMarkUnflagged(seq);
case 'move':
await client.uidMove(seq, targetMailboxPath: payload['dest'] as String);
final dest = payload['dest'] as String;
final result = await client.uidMove(seq, targetMailboxPath: dest);
await _remapEmailAfterImapMove(
client,
oldId: row.resourceId,
sourceUid: uid,
destMailboxPath: dest,
moveResult: result,
);
case 'delete':
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
@@ -2332,7 +2367,14 @@ class EmailRepositoryImpl implements EmailRepository {
await client.createMailbox(dest);
} catch (_) {}
await client.uidStore(seq, [keyword], action: imap.StoreAction.add);
await client.uidMove(seq, targetMailboxPath: dest);
final snoozeResult = await client.uidMove(seq, targetMailboxPath: dest);
await _remapEmailAfterImapMove(
client,
oldId: row.resourceId,
sourceUid: uid,
destMailboxPath: dest,
moveResult: snoozeResult,
);
case 'unsnooze':
final dest = payload['dest'] as String;
try {
@@ -2351,7 +2393,151 @@ class EmailRepositoryImpl implements EmailRepository {
);
}
}
await client.uidMove(seq, targetMailboxPath: dest);
final unsnoozeResult =
await client.uidMove(seq, targetMailboxPath: dest);
await _remapEmailAfterImapMove(
client,
oldId: row.resourceId,
sourceUid: uid,
destMailboxPath: dest,
moveResult: unsnoozeResult,
);
}
}
/// Rewrites the local row identity after an IMAP MOVE so the cache keeps
/// tracking the same physical message under its new (mailbox, UID).
///
/// The new UID is taken from the RFC 4315 `COPYUID` response code first
/// (every modern server advertises `UIDPLUS`). If that's missing we fall
/// back to `UID SEARCH HEADER Message-ID …` in the destination mailbox.
/// When neither yields a UID we leave the row in place; the next sync
/// cycle will re-fetch it as a new message and reconciliation will drop
/// the stale source-side row.
Future<void> _remapEmailAfterImapMove(
imap.ImapClient client, {
required String oldId,
required int sourceUid,
required String destMailboxPath,
required imap.GenericImapResult moveResult,
}) async {
final row = await (_db.select(_db.emails)..where((t) => t.id.equals(oldId)))
.getSingleOrNull();
if (row == null) return;
final newUid = _resolveCopyUid(moveResult, sourceUid) ??
await _searchUidByMessageId(
client,
destMailboxPath,
row.messageId,
);
if (newUid == null) {
log(
'_remapEmailAfterImapMove: could not resolve new UID for $oldId '
'after move to $destMailboxPath (no COPYUID, '
'messageId=${row.messageId}); row will be re-fetched on next sync',
);
return;
}
final newId = '${row.accountId}:$destMailboxPath:$newUid';
if (newId == oldId) return;
await _db.transaction(() async {
await _db.customStatement('PRAGMA defer_foreign_keys = ON');
await _db.customStatement(
'UPDATE email_bodies SET email_id = ?1 WHERE email_id = ?2',
[newId, oldId],
);
await (_db.update(_db.emails)..where((t) => t.id.equals(oldId))).write(
EmailsCompanion(
id: Value(newId),
uid: Value(newUid),
mailboxPath: Value(destMailboxPath),
),
);
await (_db.update(_db.pendingChanges)
..where((t) => t.resourceId.equals(oldId)))
.write(PendingChangesCompanion(resourceId: Value(newId)));
// threads.latest_email_id is a plain equality match; threads.email_ids_json
// is a JSON array of email IDs — both are safe to update via REPLACE()
// because email IDs are unique opaque strings.
await _db.customStatement(
'UPDATE threads SET latest_email_id = ?1 '
'WHERE latest_email_id = ?2',
[newId, oldId],
);
await _db.customStatement(
'UPDATE threads SET email_ids_json = '
'REPLACE(email_ids_json, ?1, ?2) '
'WHERE email_ids_json LIKE ?3',
['"$oldId"', '"$newId"', '%"$oldId"%'],
);
// UndoAction.toJson() embeds email IDs as quoted JSON strings in both
// emailIds and originalEmails[].id, so the same REPLACE() works.
await _db.customStatement(
'UPDATE undo_actions SET data_json = '
'REPLACE(data_json, ?1, ?2) '
'WHERE data_json LIKE ?3',
['"$oldId"', '"$newId"', '%"$oldId"%'],
);
});
// Rebuild thread aggregates in both mailboxes from the now-updated emails.
final threadId = row.threadId ?? newId;
await _updateThread(row.accountId, row.mailboxPath, threadId);
await _updateThread(row.accountId, destMailboxPath, threadId);
}
/// Extracts the destination UID for [sourceUid] from a MOVE/COPY result's
/// `COPYUID` response code (RFC 4315). Returns null when the server did not
/// advertise UIDPLUS or the response code is malformed.
int? _resolveCopyUid(imap.GenericImapResult result, int sourceUid) {
final code = result.responseCodeCopyUid;
if (code == null) return null;
try {
final sources = code.originalSequence?.toList();
final targets = code.targetSequence.toList();
if (sources == null) {
// Some servers omit the source set when only one message moved.
return targets.length == 1 ? targets.first : null;
}
final idx = sources.indexOf(sourceUid);
if (idx < 0 || idx >= targets.length) return null;
return targets[idx];
} catch (_) {
return null;
}
}
/// Looks up the UID of a message in [mailboxPath] by its RFC 2822
/// `Message-ID` header. Used as a fallback when the server doesn't
/// support UIDPLUS so we can still relink the local row after a move.
Future<int?> _searchUidByMessageId(
imap.ImapClient client,
String mailboxPath,
String? messageId,
) async {
if (messageId == null || messageId.isEmpty) return null;
try {
await client.selectMailboxByPath(mailboxPath);
// RFC 3501 SEARCH HEADER uses an astring for the value; quoting is safe
// for typical Message-ID syntax (no embedded quotes or backslashes).
final escaped = messageId.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
final result = await client.uidSearchMessages(
searchCriteria: 'HEADER Message-ID "$escaped"',
);
final uids = result.matchingSequence?.toList() ?? const <int>[];
if (uids.isEmpty) return null;
return uids.reduce((a, b) => a > b ? a : b);
} catch (e) {
log('_searchUidByMessageId failed for $messageId in $mailboxPath: $e');
return null;
}
}
+4
View File
@@ -239,6 +239,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
// SnackBar defaults to persist=true when an action
// is set, which disables the auto-dismiss timer.
// Explicitly opt back into duration-based dismiss.
persist: false,
content: const Text(
'Images will be loaded automatically for this sender.',
),
+4
View File
@@ -214,6 +214,10 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
// SnackBar defaults to persist=true when an
// action is set, which disables auto-dismiss.
// Explicitly opt into duration-based dismiss.
persist: false,
content: const Text(
'Images will be loaded automatically for this sender.',
),
+45 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
@@ -93,7 +94,9 @@ class UndoLogDetailScreen extends ConsumerWidget {
style: theme.textTheme.bodySmall,
),
),
...action.originalEmails.map((email) => _EmailTile(email: email)),
...action.originalEmails.map(
(email) => _EmailTile(email: email, accountId: action.accountId),
),
],
),
);
@@ -120,13 +123,14 @@ class _SectionHeader extends StatelessWidget {
}
}
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email});
class _EmailTile extends ConsumerWidget {
const _EmailTile({required this.email, required this.accountId});
final Email email;
final String accountId;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(Unknown Sender)';
@@ -134,6 +138,43 @@ class _EmailTile extends StatelessWidget {
leading: const Icon(Icons.email_outlined),
title: Text(email.subject ?? '(No Subject)'),
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openEmail(context, ref),
);
}
Future<void> _openEmail(BuildContext context, WidgetRef ref) async {
final messageId = email.messageId;
final messenger = ScaffoldMessenger.of(context);
if (messageId == null) {
messenger.showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Cannot locate this email — no Message-ID.'),
),
);
return;
}
final found = await ref
.read(emailRepositoryProvider)
.findEmailByMessageId(accountId, messageId);
if (!context.mounted) return;
if (found == null) {
messenger.showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text(
'Email no longer exists at its previous location. '
'Use Undo to restore it.',
),
),
);
return;
}
context.go(
'/accounts/$accountId'
'/mailboxes/${Uri.encodeComponent(found.mailboxPath)}'
'/emails/${Uri.encodeComponent(found.id)}',
);
}
}
+1 -1
View File
@@ -680,7 +680,7 @@ packages:
source: hosted
version: "0.13.0"
meta:
dependency: transitive
dependency: "direct main"
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
+3
View File
@@ -67,6 +67,9 @@ dependencies:
share_plus: ^13.1.0
device_info_plus: ^13.1.0
# @visibleForTesting annotation used in lib/data/repositories.
meta: ^1.16.0
dev_dependencies:
flutter_test:
sdk: flutter
+236
View File
@@ -1109,6 +1109,242 @@ void main() {
expect(spy.movedToMailbox, 'Snoozed');
},
);
test(
'move flush remaps local id/uid from COPYUID and rewrites cached bodies',
() async {
final spy = SnoozeSpyImapClient(
copyUidValidity: 1,
copyUidSourceToTarget: const {5: 42},
);
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
await r.accounts.addAccount(_account, 'pw');
const oldId = 'acc-1:INBOX:5';
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: oldId,
accountId: 'acc-1',
mailboxPath: 'Archive', // already optimistically moved
uid: 5,
receivedAt: DateTime(2024),
messageId: const Value('<msg-1@example.com>'),
threadId: const Value('thr-1'),
),
);
await r.db.into(r.db.emailBodies).insert(
EmailBodiesCompanion.insert(
emailId: oldId,
textBody: const Value('cached body'),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'Email',
resourceId: oldId,
changeType: 'move',
payload: jsonEncode({
'uid': 5,
'mailboxPath': 'INBOX',
'dest': 'Archive',
}),
createdAt: DateTime.now(),
),
);
await r.emails.flushPendingChanges('acc-1', 'pw');
// Pending change drained.
expect(await r.db.select(r.db.pendingChanges).get(), isEmpty);
// Old id is gone; new id reflects destination mailbox + new UID.
expect(await r.emails.getEmail(oldId), isNull);
const newId = 'acc-1:Archive:42';
final moved = await r.emails.getEmail(newId);
expect(moved, isNotNull);
expect(moved!.uid, 42);
expect(moved.mailboxPath, 'Archive');
// Body cache follows the new id.
final bodies = await r.db.select(r.db.emailBodies).get();
expect(bodies, hasLength(1));
expect(bodies.first.emailId, newId);
expect(bodies.first.textBody, 'cached body');
},
);
test(
'move flush falls back to UID SEARCH HEADER Message-ID without UIDPLUS',
() async {
const messageId = '<msg-1@example.com>';
const criteria = 'HEADER Message-ID "$messageId"';
final spy = SnoozeSpyImapClient(
// No copyUidValidity → no COPYUID in the MOVE response.
searchResults: const {
criteria: [99],
},
);
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
await r.accounts.addAccount(_account, 'pw');
const oldId = 'acc-1:INBOX:5';
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: oldId,
accountId: 'acc-1',
mailboxPath: 'Archive',
uid: 5,
receivedAt: DateTime(2024),
messageId: const Value(messageId),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'Email',
resourceId: oldId,
changeType: 'move',
payload: jsonEncode({
'uid': 5,
'mailboxPath': 'INBOX',
'dest': 'Archive',
}),
createdAt: DateTime.now(),
),
);
await r.emails.flushPendingChanges('acc-1', 'pw');
expect(spy.lastSearchCriteria, criteria);
const newId = 'acc-1:Archive:99';
final moved = await r.emails.getEmail(newId);
expect(moved, isNotNull);
expect(moved!.uid, 99);
},
);
test(
'move flush rewrites pending undo_actions referencing the old id',
() async {
final spy = SnoozeSpyImapClient(
copyUidValidity: 1,
copyUidSourceToTarget: const {5: 42},
);
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
await r.accounts.addAccount(_account, 'pw');
const oldId = 'acc-1:INBOX:5';
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: oldId,
accountId: 'acc-1',
mailboxPath: 'Archive',
uid: 5,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'Email',
resourceId: oldId,
changeType: 'move',
payload: jsonEncode({
'uid': 5,
'mailboxPath': 'INBOX',
'dest': 'Archive',
}),
createdAt: DateTime.now(),
),
);
// An undo entry created when the user did the move, referencing oldId
// in both emailIds and originalEmails[].id.
await r.db.into(r.db.undoActions).insert(
UndoActionsCompanion.insert(
id: 'undo-1',
accountId: 'acc-1',
dataJson: jsonEncode({
'id': 'undo-1',
'accountId': 'acc-1',
'type': 'move',
'emailIds': [oldId],
'sourceMailboxPath': 'INBOX',
'destinationMailboxPath': 'Archive',
'timestamp': DateTime(2024).toIso8601String(),
'originalEmails': [
{
'id': oldId,
'accountId': 'acc-1',
'mailboxPath': 'INBOX',
'uid': 5,
'receivedAt': DateTime(2024).toIso8601String(),
'from': [],
'to': [],
'cc': [],
'isSeen': false,
'isFlagged': false,
'hasAttachment': false,
},
],
}),
createdAt: DateTime(2024),
),
);
await r.emails.flushPendingChanges('acc-1', 'pw');
const newId = 'acc-1:Archive:42';
final stored = await r.db.select(r.db.undoActions).getSingle();
final json = jsonDecode(stored.dataJson) as Map<String, dynamic>;
expect(json['emailIds'], [newId]);
expect(
(json['originalEmails'] as List).first as Map<String, dynamic>,
containsPair('id', newId),
);
},
);
test(
'reconciliation skips rows with a pending move so they are not wiped',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
const oldId = 'acc-1:INBOX:5';
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: oldId,
accountId: 'acc-1',
mailboxPath: 'Archive', // optimistically moved
uid: 5,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.pendingChanges).insert(
PendingChangesCompanion.insert(
accountId: 'acc-1',
resourceType: 'Email',
resourceId: oldId,
changeType: 'move',
payload: jsonEncode({
'uid': 5,
'mailboxPath': 'INBOX',
'dest': 'Archive',
}),
createdAt: DateTime.now(),
),
);
// Run the deletion-reconciliation pass with a destination snapshot
// that does NOT contain UID 5 — the row would be wiped without the
// in-flight guard.
await r.emails
.reconcileDeletedImapForTest('acc-1', 'Archive', const []);
expect(await r.emails.getEmail(oldId), isNotNull);
},
);
});
group('Snooze', () {
+43 -1
View File
@@ -19,9 +19,25 @@ class FakeImapClient extends imap.ImapClient {
/// Spy IMAP client that records snooze-related operations and succeeds silently.
class SnoozeSpyImapClient extends FakeImapClient {
SnoozeSpyImapClient({
this.copyUidValidity,
this.copyUidSourceToTarget = const {},
this.searchResults = const {},
});
String? selectedMailbox;
String? createdMailbox;
String? movedToMailbox;
String? lastSearchCriteria;
/// When non-null, `uidMove` returns a `COPYUID` response code built from
/// these mappings (sourceUid → destinationUid) for the moved sequence.
final int? copyUidValidity;
final Map<int, int> copyUidSourceToTarget;
/// Maps a `UID SEARCH HEADER Message-ID …` search criteria (the literal
/// IMAP atom incl. quotes) to the UIDs the fake should return.
final Map<String, List<int>> searchResults;
imap.Mailbox _fakeMailbox(String path) => imap.Mailbox(
encodedName: path,
@@ -63,7 +79,33 @@ class SnoozeSpyImapClient extends FakeImapClient {
String? targetMailboxPath,
}) async {
movedToMailbox = targetMailboxPath;
return imap.GenericImapResult();
final result = imap.GenericImapResult();
if (copyUidValidity != null && copyUidSourceToTarget.isNotEmpty) {
final sources = sequence.toList();
final mapped = sources
.where(copyUidSourceToTarget.containsKey)
.map((uid) => copyUidSourceToTarget[uid]!)
.toList();
if (mapped.isNotEmpty) {
final src = sources.join(',');
final dst = mapped.join(',');
result.responseCode = 'COPYUID $copyUidValidity $src $dst';
}
}
return result;
}
@override
Future<imap.SearchImapResult> uidSearchMessages({
String searchCriteria = 'UNSEEN',
List<imap.ReturnOption>? returnOptions,
Duration? responseTimeout,
}) async {
lastSearchCriteria = searchCriteria;
final hits = searchResults[searchCriteria] ?? const <int>[];
final result = imap.SearchImapResult()
..matchingSequence = imap.MessageSequence.fromIds(hits, isUid: true);
return result;
}
@override
+48
View File
@@ -582,6 +582,54 @@ void main() {
expect(find.textContaining('Structure not available'), findsOneWidget);
});
testWidgets(
'Load remote images snack bar auto-dismisses after 3 seconds',
(tester) async {
const body = EmailBody(
emailId: 'acc-1:42',
htmlBody: '<p>Hello <img src="https://example.com/x.png"/></p>',
attachments: [],
);
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
// The "Load remote images" button is visible because the sender is
// not yet trusted.
expect(find.text('Load remote images'), findsOneWidget);
await tester.tap(find.text('Load remote images'));
// Settle the snack bar enter animation and the setState rebuild
// that swaps in the image-loading WebView.
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Snack bar must be visible.
expect(
find.text('Images will be loaded automatically for this sender.'),
findsOneWidget,
);
// After 3 seconds (the snack bar's duration) plus the reverse
// animation, the snack bar must be gone.
// Regression test for #484: SnackBar with an action defaults to
// persist=true, which disables auto-dismiss — explicit persist:false
// restores duration-based dismissal.
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(
find.text('Images will be loaded automatically for this sender.'),
findsNothing,
);
},
);
});
}
@@ -249,5 +249,59 @@ void main() {
expect(find.text('Body content here'), findsOneWidget);
});
testWidgets(
'Load remote images snack bar auto-dismisses after 3 seconds',
(tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
htmlBody:
'<p>Hi <img src="https://example.com/x.png"/></p>',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Load remote images'), findsOneWidget);
await tester.tap(find.text('Load remote images'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(
find.text('Images will be loaded automatically for this sender.'),
findsOneWidget,
);
// Regression test for #484: SnackBar with an action defaults to
// persist=true, which disables auto-dismiss — explicit persist:false
// restores duration-based dismissal.
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(
find.text('Images will be loaded automatically for this sender.'),
findsNothing,
);
},
);
});
}
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
import 'helpers.dart';
// FakeEmailRepository subclass that returns a pre-configured email from
// findEmailByMessageId, so the tap handler in UndoLogDetailScreen can be
// exercised without a real database.
class _LookupEmailRepository extends FakeEmailRepository {
_LookupEmailRepository(this._lookup);
final Email? _lookup;
@override
Future<Email?> findEmailByMessageId(
String accountId,
String messageId,
) async =>
_lookup;
}
UndoAction _action({
required List<Email> originalEmails,
String accountId = 'acc-1',
}) =>
UndoAction(
id: 'undo-1',
accountId: accountId,
type: UndoType.move,
emailIds: originalEmails.map((e) => e.id).toList(),
sourceMailboxPath: 'INBOX',
destinationMailboxPath: 'Archive',
originalEmails: originalEmails,
timestamp: DateTime(2024, 6),
);
Email _emailWith({
String id = 'acc-1:42',
String mailboxPath = 'INBOX',
String? messageId = '<msg-1@example.com>',
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: mailboxPath,
uid: 42,
subject: 'Hello world',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: false,
isFlagged: false,
hasAttachment: false,
messageId: messageId,
);
// Builds a minimal app whose initial location is the undo log detail screen
// for [action]. A placeholder email-detail route records its visit so the
// test can assert which path the tap navigated to.
Widget _buildApp({
required UndoAction action,
required FakeEmailRepository emailRepo,
ValueNotifier<String?>? lastEmailRoute,
}) {
final router = GoRouter(
initialLocation: '/undo-detail',
routes: [
GoRoute(
path: '/undo-detail',
builder: (ctx, state) => UndoLogDetailScreen(action: action),
),
GoRoute(
path: '/accounts/:accountId/mailboxes/:mailboxPath/emails/:emailId',
builder: (ctx, state) {
lastEmailRoute?.value = state.uri.toString();
return const Scaffold(body: Text('email-detail-route'));
},
),
],
);
return ProviderScope(
overrides: [
emailRepositoryProvider.overrideWithValue(emailRepo),
],
child: MaterialApp.router(routerConfig: router),
);
}
void main() {
group('UndoLogDetailScreen email row tap', () {
testWidgets('navigates to the current location returned by lookup', (
tester,
) async {
// Original row recorded INBOX/42; after the move it now lives in
// Archive with a fresh UID — the lookup is what bridges that gap.
final original = _emailWith();
final current = _emailWith(id: 'acc-1:77', mailboxPath: 'Archive');
final lastRoute = ValueNotifier<String?>(null);
await tester.pumpWidget(
_buildApp(
action: _action(originalEmails: [original]),
emailRepo: _LookupEmailRepository(current),
lastEmailRoute: lastRoute,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Hello world'));
await tester.pumpAndSettle();
expect(find.text('email-detail-route'), findsOneWidget);
expect(
lastRoute.value,
'/accounts/acc-1/mailboxes/Archive/emails/acc-1%3A77',
);
});
testWidgets('shows snackbar when lookup returns null', (tester) async {
final original = _emailWith();
final lastRoute = ValueNotifier<String?>(null);
await tester.pumpWidget(
_buildApp(
action: _action(originalEmails: [original]),
emailRepo: _LookupEmailRepository(null),
lastEmailRoute: lastRoute,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Hello world'));
await tester.pump();
expect(
find.textContaining('Email no longer exists'),
findsOneWidget,
);
expect(lastRoute.value, isNull);
expect(find.text('email-detail-route'), findsNothing);
});
testWidgets('shows snackbar when email has no Message-ID', (tester) async {
final original = _emailWith(messageId: null);
final lastRoute = ValueNotifier<String?>(null);
await tester.pumpWidget(
_buildApp(
action: _action(originalEmails: [original]),
// Lookup would succeed if called, but with no Message-ID the
// tap handler must short-circuit before reaching it.
emailRepo: _LookupEmailRepository(_emailWith()),
lastEmailRoute: lastRoute,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Hello world'));
await tester.pump();
expect(find.textContaining('no Message-ID'), findsOneWidget);
expect(lastRoute.value, isNull);
expect(find.text('email-detail-route'), findsNothing);
});
});
}