Compare commits

..
Author SHA1 Message Date
agentloop 05c0334532 plan: refresh plan for issue #484 2026-06-08 14:19:39 +00:00
28 changed files with 384 additions and 994 deletions
-10
View File
@@ -1,10 +0,0 @@
{
"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["loop/code"]] if "loop/code" in label_map else []
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
@@ -1,44 +0,0 @@
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"
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.0"
}
}
-1
View File
@@ -123,4 +123,3 @@ dagger-certs
/go
.last_deployed_sha
.fail_count
/*.kubeconfig
+5 -5
View File
@@ -26,13 +26,13 @@ repos:
- id: forbidden-files-hook
name: check for forbidden home-directory files
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
pass_filenames: false
always_run: true
- id: dart-check
name: dart format (autofix) + check-fast (parallel)
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && dagger call --progress=plain -q -m ci --source=. check-fast'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
pass_filenames: false
always_run: true
- id: ci-no-direct-dagger
@@ -50,12 +50,12 @@ repos:
- id: ci-image-exists
name: verify container images in ci/main.go are reachable
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
pass_filenames: false
files: ^(ci/main\.go|\.fvmrc)$
- id: dagger-versions-aligned
name: verify Dagger version is consistent across dagger.json, Dockerfile and DAGGER.md
name: verify Dagger version is consistent across dagger.json, flake.nix, Dockerfile and DAGGER.md
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
pass_filenames: false
files: ^(ci/dagger\.json|\.forgejo/Dockerfile|DAGGER\.md)$
files: ^(ci/dagger\.json|flake\.nix|\.forgejo/Dockerfile|DAGGER\.md)$
-7
View File
@@ -54,13 +54,6 @@ 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
-59
View File
@@ -1,59 +0,0 @@
# Development and Testing Container for SharedInbox
# Replaces the Nix shell environment.
FROM ghcr.io/cirruslabs/flutter:3.44.0
# Install Linux desktop build and test dependencies, Go, NodeJS, python3, and utilities
RUN apt-get update && apt-get install -y --no-install-recommends \
clang \
cmake \
ninja-build \
pkg-config \
libgtk-3-dev \
liblzma-dev \
libsecret-1-dev \
libgcrypt20-dev \
libjsoncpp-dev \
sqlite3 \
iproute2 \
netcat-openbsd \
xvfb \
libosmesa6 \
libegl1 \
lld \
git \
curl \
jq \
python3-pip \
nodejs \
npm \
hugo \
lcov \
rsync \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Install Task runner
RUN curl -fsSL https://taskfile.dev/install.sh \
| sh -s -- -b /usr/local/bin v3.48.0
# Install Dagger CLI
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
# Install python packages (Play Store API clients + pre-commit)
RUN pip install --break-system-packages --no-cache-dir \
google-api-python-client \
google-auth-httplib2 \
httplib2 \
pre-commit==4.5.1
# Install acpx CLI globally
RUN npm install -g acpx@0.10.0
# Setup user "ci"
RUN useradd -m -s /bin/bash ci
USER ci
ENV HOME=/home/ci
ENV PATH=/home/ci/.pub-cache/bin:$PATH
WORKDIR /src
+100
View File
@@ -0,0 +1,100 @@
## Root cause analysis
The "Load remote images" button is rendered in two places: `lib/ui/screens/email_detail_screen.dart:228-262` (single mail view) and `lib/ui/screens/thread_detail_screen.dart:203-237` (thread view). Both call the same pattern:
```dart
onPressed: () {
setState(() => _loadRemoteImages = true); // 1. schedule rebuild
if (senderEmail != null) {
unawaited(...addTrustedImageSender(senderEmail)); // 2. fire-and-forget DB write
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( // 3. queue snack bar
duration: const Duration(seconds: 3),
...
));
}
}
```
Although `duration: 3s` is already set, the snack bar fails to auto-dismiss. This mirrors the bug fixed in PR #401 (issue #399): there, a snack bar fired during a navigation transition and the duration timer "didn't start correctly" because the snack bar was queued on an unstable scaffold.
Here, the analogous instability comes from three rebuilds that all land between `showSnackBar` and the moment the SnackBar's enter-animation would normally complete and start its dismiss timer:
1. The synchronous `setState` flips `_loadRemoteImages``true`, which immediately removes the "Load remote images" button (the very widget whose `onPressed` was running) and swaps the `SecureEmailWebView` into the rebuilt subtree with `loadRemoteImages: true`. The WebView's `didUpdateWidget` then triggers an async `loadHtmlString` reload (see `lib/ui/widgets/secure_email_webview.dart:100-106`), which subsequently calls `setState(() => _height = h)` inside `_measureHeight`.
2. The fire-and-forget `addTrustedImageSender` write resolves a moment later, the `trustedImageSendersProvider` stream emits, and `ref.watch(trustedImageSendersProvider)` in `email_detail_screen.dart:197` causes another rebuild of the whole screen body — including the `Scaffold`'s body subtree that hosts the snack bar overlay's host context.
3. These rebuilds happen during the SnackBar's enter animation, so the `_SnackBarState` ends up holding stale animation state and the per-snack-bar timer that schedules `hideCurrentSnackBar` after `duration` never fires.
## Plan
### Fix
Queue the snack bar **before** mutating state, so it reaches `ScaffoldMessenger` while the Scaffold subtree is still stable, and defer the state change to a post-frame callback so the snack bar's enter-animation can finish before the WebView reload and the provider-driven rebuild run.
In `lib/ui/screens/email_detail_screen.dart`, replace the body of `OutlinedButton.icon.onPressed` at lines 231-261 with:
```dart
onPressed: () {
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
),
),
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _loadRemoteImages = true);
});
},
```
Apply the same reordering to `lib/ui/screens/thread_detail_screen.dart:206-236`.
The key changes:
- `showSnackBar` runs first, on the still-stable scaffold subtree.
- `setState` (which triggers WebView swap-in and subsequent rebuilds) is deferred to a post-frame callback.
- When `senderEmail == null` (no trusted-sender to register, so no snack bar), the post-frame callback still flips `_loadRemoteImages` to true — preserving existing behavior of the button working even for unknown senders.
### Tests
Add a widget test in `test/widget/email_detail_screen_test.dart` that:
1. Pumps an `EmailDetailScreen` with an HTML body and a non-empty `From` header.
2. Taps the "Load remote images" button.
3. Verifies the snack bar with text "Images will be loaded automatically for this sender." appears.
4. Calls `tester.pump(const Duration(seconds: 4))` (or uses `tester.pumpAndSettle` after a 3.5s pump).
5. Verifies the snack bar is gone (`expect(find.byType(SnackBar), findsNothing)`).
6. Verifies `_loadRemoteImages` did flip, by checking that the "Load remote images" button is no longer present.
Add an analogous test in `test/widget/thread_detail_screen_test.dart` (or wherever thread tests live; create the file if it does not exist yet — use the email_detail test as a template).
### Out of scope
- The "First update agent loop, fix search bug" line in the issue body is two unrelated todo notes the reporter jotted down (the search bug is tracked separately). This plan does not address them.
- Other `showSnackBar` call sites in `email_detail_screen.dart` (download success/failure, copy-to-clipboard, raw-email errors, etc.) are not affected by the same rebuild pattern and stay unchanged.
### Verification checklist
- [ ] `dart test` (or the project's `task test` equivalent) passes, including the two new widget tests.
- [ ] Manual: open a single mail in `EmailDetailScreen` with HTML body from a sender not yet trusted; tap "Load remote images"; verify snack bar appears, images load, and snack bar disappears after ~3 seconds.
- [ ] Manual: tap "View" on the snack bar before it dismisses; verify it navigates to `/accounts/trusted-senders` and that the snack bar is dismissed by the navigation as expected.
- [ ] Manual: repeat in `ThreadDetailScreen`.
-11
View File
@@ -81,17 +81,6 @@ 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).
+1 -1
View File
@@ -544,7 +544,7 @@ tasks:
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (local/fvm)
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
cmds:
+2 -9
View File
@@ -814,14 +814,7 @@ 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().
// `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)`}).
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
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.
@@ -903,7 +896,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
}
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
func (m *Ci) UploadToPlayStore(
ctx context.Context,
aab *dagger.File,
Generated
+82
View File
@@ -0,0 +1,82 @@
{
"nodes": {
"dagger": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778107833,
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
"owner": "dagger",
"repo": "nix",
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
"type": "github"
},
"original": {
"owner": "dagger",
"repo": "nix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778737229,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"dagger": "dagger",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+166
View File
@@ -0,0 +1,166 @@
{
description = "SharedInbox IMAP/SMTP Flutter client";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
dagger.url = "github:dagger/nix";
dagger.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, flake-utils, dagger }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# All Linux desktop runtime libraries needed by flutter build linux and
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
# it for both buildInputs and LD_LIBRARY_PATH / PKG_CONFIG_PATH.
linuxDesktopLibs = with pkgs; [
gtk3
libsecret
fontconfig
libepoxy
mesa
libGL # libglvnd — vendor-neutral GL/EGL/GLX dispatch layer
at-spi2-core
glib
pango
cairo
gdk-pixbuf
harfbuzz
# Dagger remote setup dependencies
stunnel
netcat
];
fgj = pkgs.stdenv.mkDerivation {
pname = "fgj";
version = "0.4.0";
src = pkgs.fetchurl {
url = "https://codeberg.org/romaintb/fgj/releases/download/v0.4.0/fgj_linux_amd64";
sha256 = "07pia03facvvxq9i1dgl7p47ccv1iqj4drpkp45gvw26d4afkbj7";
};
dontUnpack = true;
installPhase = ''
mkdir -p $out/bin
cp $src $out/bin/fgj
chmod +x $out/bin/fgj
'';
};
# The dagger/nix flake's Nix wrapper is a broken self-exec loop, so we
# fetch the CLI binary directly. Keep this version in lockstep with
# ci/dagger.json (engineVersion) and .forgejo/Dockerfile (DAGGER_VERSION) —
# scripts/check_dagger_versions.sh enforces this.
daggerCli = pkgs.stdenv.mkDerivation {
pname = "dagger";
version = "0.20.8";
src = pkgs.fetchurl {
url = "https://dl.dagger.io/dagger/releases/0.20.8/dagger_v0.20.8_linux_amd64.tar.gz";
sha256 = "1ns6wq2z1skd2fq9lbrcali0s8kn24p3haamnjjgchg6zlv6b960";
};
sourceRoot = ".";
installPhase = ''
mkdir -p $out/bin
cp dagger $out/bin/dagger
chmod +x $out/bin/dagger
'';
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Dagger CLI
daggerCli
# Go compiler — for Dagger development
go
# Java JDK — required by Gradle for Android builds
# Task runner
go-task
# Flutter version manager — needed for host builds (task build-linux, task run)
fvm
# Git hooks
pre-commit
# Linux desktop build + runtime dependencies (flutter build linux / task run)
] ++ linuxDesktopLibs ++ (with pkgs; [
pkg-config
clang
cmake
ninja
# Local IMAP/SMTP dev server for integration tests
stalwart-mail
# Headless display for UI integration tests
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
# Coverage merging (flutter test --merge-coverage requires lcov)
lcov
# Website
hugo
# Utilities
git
curl
jq
sqlite
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
librsvg # rsvg-convert — SVG→PNG for generate-icons task
]);
shellHook = ''
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
export IN_NIX_SHELL=1
# Point Dagger client at the running engine socket
export DAGGER_HOST=unix:///run/dagger/engine.sock
# Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true
# Expose dev headers to cmake's FindPkgConfig.
# The nix pkg-config wrapper works in bash but cmake invokes pkg-config
# as a subprocess and needs PKG_CONFIG_PATH set explicitly.
export PKG_CONFIG_PATH="${pkgs.gtk3.dev}/lib/pkgconfig:${pkgs.glib.dev}/lib/pkgconfig:${pkgs.pango.dev}/lib/pkgconfig:${pkgs.cairo.dev}/lib/pkgconfig:${pkgs.gdk-pixbuf.dev}/lib/pkgconfig:${pkgs.at-spi2-core.dev}/lib/pkgconfig:${pkgs.harfbuzz.dev}/lib/pkgconfig:${pkgs.libsecret}/lib/pkgconfig:${pkgs.fontconfig.dev}/lib/pkgconfig:${pkgs.libepoxy}/lib/pkgconfig:$PKG_CONFIG_PATH"
# Nix ld uses --no-copy-dt-needed-entries (strict mode): transitive shared-lib
# deps are not followed automatically, so link them explicitly.
export LDFLAGS="-L${pkgs.fontconfig.lib}/lib -lfontconfig $LDFLAGS"
# Make nix-built runtime libs visible to the dynamic linker so the
# Flutter Linux bundle and integration-ui tests can run.
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath linuxDesktopLibs}:$LD_LIBRARY_PATH"
# Wire the libglvnd dispatch to the nix mesa vendor ICDs so GTK/Flutter
# can create an OpenGL (EGL + GLX) context under Xvfb without a real GPU.
export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d"
export __GLX_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/lib"
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_LOADER_DRIVER_OVERRIDE=softpipe
echo "SharedInbox Flutter dev environment ready."
echo " Analyze : task analyze"
echo " Unit tests : task test"
echo " Integration : task integration"
echo " All checks : task check"
echo " Run (Linux) : task run"
echo " Start Stalwart : stalwart-dev/start"
'';
};
}
);
}
@@ -6,7 +6,6 @@ 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';
@@ -729,14 +728,6 @@ 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,
@@ -761,28 +752,10 @@ 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();
}
@@ -2344,15 +2317,7 @@ class EmailRepositoryImpl implements EmailRepository {
? await client.uidMarkFlagged(seq)
: await client.uidMarkUnflagged(seq);
case 'move':
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,
);
await client.uidMove(seq, targetMailboxPath: payload['dest'] as String);
case 'delete':
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
@@ -2367,14 +2332,7 @@ class EmailRepositoryImpl implements EmailRepository {
await client.createMailbox(dest);
} catch (_) {}
await client.uidStore(seq, [keyword], action: imap.StoreAction.add);
final snoozeResult = await client.uidMove(seq, targetMailboxPath: dest);
await _remapEmailAfterImapMove(
client,
oldId: row.resourceId,
sourceUid: uid,
destMailboxPath: dest,
moveResult: snoozeResult,
);
await client.uidMove(seq, targetMailboxPath: dest);
case 'unsnooze':
final dest = payload['dest'] as String;
try {
@@ -2393,151 +2351,7 @@ class EmailRepositoryImpl implements EmailRepository {
);
}
}
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;
await client.uidMove(seq, targetMailboxPath: dest);
}
}
-4
View File
@@ -239,10 +239,6 @@ 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,10 +214,6 @@ 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.',
),
+4 -45
View File
@@ -1,6 +1,5 @@
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';
@@ -94,9 +93,7 @@ class UndoLogDetailScreen extends ConsumerWidget {
style: theme.textTheme.bodySmall,
),
),
...action.originalEmails.map(
(email) => _EmailTile(email: email, accountId: action.accountId),
),
...action.originalEmails.map((email) => _EmailTile(email: email)),
],
),
);
@@ -123,14 +120,13 @@ class _SectionHeader extends StatelessWidget {
}
}
class _EmailTile extends ConsumerWidget {
const _EmailTile({required this.email, required this.accountId});
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email});
final Email email;
final String accountId;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(Unknown Sender)';
@@ -138,43 +134,6 @@ class _EmailTile extends ConsumerWidget {
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: "direct main"
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
-3
View File
@@ -67,9 +67,6 @@ 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
+8 -2
View File
@@ -13,6 +13,11 @@ ROOT=$(git rev-parse --show-toplevel)
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
| sed -E 's/.*"v?([^"]+)"$/\1/')
# flake.nix — the dagger021 derivation's CLI download URL.
flake_nix=$(grep -oE 'dagger_v[0-9]+\.[0-9]+\.[0-9]+_linux' "$ROOT/flake.nix" \
| head -n1 \
| sed -E 's/dagger_v([0-9.]+)_linux/\1/')
# .forgejo/Dockerfile — DAGGER_VERSION env on the install line.
dockerfile=$(grep -oE 'DAGGER_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/.forgejo/Dockerfile" \
| head -n1 \
@@ -24,10 +29,11 @@ dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
| sed -E 's@.*/v@@')
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
printf 'flake.nix dagger021 = %s\n' "$flake_nix"
printf '.forgejo/Dockerf. DAGGER_VERSION= %s\n' "$dockerfile"
printf 'DAGGER.md engine tag = v%s\n' "$dagger_md"
for v in "$dockerfile" "$dagger_md"; do
for v in "$flake_nix" "$dockerfile" "$dagger_md"; do
if [ -z "$v" ]; then
echo "ERROR: failed to parse a Dagger version reference." >&2
exit 1
@@ -35,7 +41,7 @@ for v in "$dockerfile" "$dagger_md"; do
if [ "$v" != "$dagger_json" ]; then
echo "" >&2
echo "ERROR: Dagger versions are out of sync." >&2
echo " Align ci/dagger.json, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
echo " Align ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
exit 1
fi
done
+9 -16
View File
@@ -1,11 +1,5 @@
#!/usr/bin/env python3
"""Upload an Android App Bundle to the Google Play Store.
The bundle is published to every track in ``TRACKS`` within a single Play edit,
so internal testing and closed testing share the same version code. ``alpha``
is what the Play Console labels "Closed testing"; publishing there removes the
need to manually drag-and-drop the AAB into the closed-testing release form.
"""
"""Upload an Android App Bundle to the Google Play Store internal track."""
import json
import os
@@ -17,7 +11,7 @@ from google.oauth2 import service_account
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACKS = ("internal", "alpha")
TRACK = "internal"
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
_MAX_UPLOAD_ATTEMPTS = 3
@@ -100,20 +94,19 @@ def main():
version_code = bundle["versionCode"]
print(f"Uploaded AAB, version code: {version_code}")
for track in TRACKS:
track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{track}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
track_resp = session.put(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
timeout=30,
)
track_resp.raise_for_status()
commit_resp = session.post(
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
timeout=30,
)
commit_resp.raise_for_status()
print(f"Deployed version {version_code} to tracks: {', '.join(TRACKS)}")
print(f"Deployed version {version_code} to {TRACK} track")
if __name__ == "__main__":
-24
View File
@@ -95,30 +95,6 @@ class TestMainHappyPath(unittest.TestCase):
track_call = session.put.call_args_list[0]
self.assertIn("/tracks/", track_call[0][0])
def test_updates_all_configured_tracks(self):
session = self._run_main()
track_urls = [c[0][0] for c in session.put.call_args_list]
self.assertEqual(len(track_urls), len(deploy_playstore.TRACKS))
for track in deploy_playstore.TRACKS:
self.assertTrue(
any(url.endswith(f"/tracks/{track}") for url in track_urls),
f"no PUT to /tracks/{track} (saw {track_urls})",
)
def test_commits_after_all_track_updates(self):
session = self._run_main()
# All PUTs are track updates; commit is the second POST after the
# initial edit-create. Verify PUTs precede the commit by checking
# mock_calls order across both methods.
method_order = [c[0] for c in session.method_calls]
commit_idx = next(
i for i, m in enumerate(method_order)
if m == "post" and ":commit" in session.method_calls[i][1][0]
)
put_indices = [i for i, m in enumerate(method_order) if m == "put"]
self.assertEqual(len(put_indices), len(deploy_playstore.TRACKS))
self.assertTrue(all(i < commit_idx for i in put_indices))
class TestUploadRetry(unittest.TestCase):
def _run_main(self, upload_side_effects, sleep_mock=None):
-236
View File
@@ -1109,242 +1109,6 @@ 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', () {
+1 -43
View File
@@ -19,25 +19,9 @@ 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,
@@ -79,33 +63,7 @@ class SnoozeSpyImapClient extends FakeImapClient {
String? targetMailboxPath,
}) async {
movedToMailbox = targetMailboxPath;
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;
return imap.GenericImapResult();
}
@override
-48
View File
@@ -582,54 +582,6 @@ 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,59 +249,5 @@ 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,
);
},
);
});
}
@@ -1,176 +0,0 @@
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);
});
});
}