Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29bc403180 | ||
|
|
0141d86361 | ||
|
|
f1f7de7b4d | ||
|
|
de2b9d22b4 | ||
|
|
0297701829 | ||
|
|
ee238b85c7 | ||
|
|
f0eff7dc7c | ||
|
|
8ea5237991 |
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
|
||||
@@ -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
@@ -680,7 +680,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user