Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29bc403180 | ||
|
|
0141d86361 | ||
|
|
f1f7de7b4d | ||
|
|
de2b9d22b4 | ||
|
|
0297701829 | ||
|
|
ee238b85c7 | ||
|
|
f0eff7dc7c | ||
|
|
517f7a6aa8 | ||
|
|
8ea5237991 | ||
|
|
1e5093b631 | ||
|
|
c1ee8ec1f4 | ||
|
|
7ce9eddabf | ||
|
|
8592bba9e3 |
@@ -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")
|
repo_labels = api_get("/labels")
|
||||||
label_map = {l["name"]: l["id"] for l in repo_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"
|
title = "Firebase Tests failed — find root cause and fix"
|
||||||
body = (
|
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"
|
||||||
@@ -123,3 +123,4 @@ dagger-certs
|
|||||||
/go
|
/go
|
||||||
.last_deployed_sha
|
.last_deployed_sha
|
||||||
.fail_count
|
.fail_count
|
||||||
|
/*.kubeconfig
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ repos:
|
|||||||
- id: forbidden-files-hook
|
- id: forbidden-files-hook
|
||||||
name: check for forbidden home-directory files
|
name: check for forbidden home-directory files
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
@@ -50,6 +50,12 @@ repos:
|
|||||||
- id: ci-image-exists
|
- id: ci-image-exists
|
||||||
name: verify container images in ci/main.go are reachable
|
name: verify container images in ci/main.go are reachable
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
files: ^(ci/main\.go|\.fvmrc)$
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
- id: dagger-versions-aligned
|
||||||
|
name: verify Dagger version is consistent across dagger.json, 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)$
|
||||||
|
|||||||
@@ -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
|
optimistic local update; `flushPendingChanges` drains the queue over a single
|
||||||
IMAP connection at the start of each sync cycle.
|
IMAP connection at the start of each sync cycle.
|
||||||
- Sent messages are appended to the Sent folder after SMTP delivery.
|
- 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.
|
- Sync retries use exponential backoff after failures.
|
||||||
|
|
||||||
### Cross-protocol
|
### Cross-protocol
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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
|
||||||
@@ -81,6 +81,17 @@ start()
|
|||||||
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
||||||
folder is re-scanned and the checkpoint is reset.
|
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
|
**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).
|
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -544,7 +544,7 @@ tasks:
|
|||||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||||
|
|
||||||
deploy-android-bundle:
|
deploy-android-bundle:
|
||||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (local/fvm)
|
||||||
deps: [build-android-bundle-local]
|
deps: [build-android-bundle-local]
|
||||||
dotenv: [".env"]
|
dotenv: [".env"]
|
||||||
cmds:
|
cmds:
|
||||||
@@ -712,6 +712,11 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/check_ci_images.sh
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
|
check-dagger-versions:
|
||||||
|
desc: Verify ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md pin the same Dagger version
|
||||||
|
cmds:
|
||||||
|
- scripts/check_dagger_versions.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
|
|||||||
+9
-2
@@ -814,7 +814,14 @@ func (m *Ci) DeployApk(
|
|||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.firebaseBase().
|
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").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||||
@@ -896,7 +903,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
|
|||||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
|
||||||
func (m *Ci) UploadToPlayStore(
|
func (m *Ci) UploadToPlayStore(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
aab *dagger.File,
|
aab *dagger.File,
|
||||||
|
|||||||
Generated
-82
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
{
|
|
||||||
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 pins 0.20.8, whose Nix wrapper is a broken self-exec
|
|
||||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
|
||||||
dagger021 = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "dagger";
|
|
||||||
version = "0.21.4";
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
|
||||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
|
||||||
};
|
|
||||||
sourceRoot = ".";
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp dagger $out/bin/dagger
|
|
||||||
chmod +x $out/bin/dagger
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
# Dagger CLI
|
|
||||||
dagger021
|
|
||||||
|
|
||||||
# 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,6 +6,7 @@ import 'dart:math' as math;
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
@@ -728,6 +729,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await _saveSyncState(accountId, resourceType, jsonEncode(data));
|
await _saveSyncState(accountId, resourceType, jsonEncode(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> reconcileDeletedImapForTest(
|
||||||
|
String accountId,
|
||||||
|
String mailboxPath,
|
||||||
|
List<int> serverUids,
|
||||||
|
) =>
|
||||||
|
_reconcileDeletedImap(accountId, mailboxPath, serverUids);
|
||||||
|
|
||||||
Future<void> _reconcileDeletedImap(
|
Future<void> _reconcileDeletedImap(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
@@ -752,10 +761,28 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return;
|
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 serverUidSet = serverUids.toSet();
|
||||||
final affectedThreads = <String>{};
|
final affectedThreads = <String>{};
|
||||||
for (final row in localRows) {
|
for (final row in localRows) {
|
||||||
if (!serverUidSet.contains(row.uid)) {
|
if (!serverUidSet.contains(row.uid)) {
|
||||||
|
if (inFlightSet.contains(row.id)) continue;
|
||||||
affectedThreads.add(row.threadId ?? row.id);
|
affectedThreads.add(row.threadId ?? row.id);
|
||||||
await (_db.delete(_db.emails)..where((t) => t.id.equals(row.id))).go();
|
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.uidMarkFlagged(seq)
|
||||||
: await client.uidMarkUnflagged(seq);
|
: await client.uidMarkUnflagged(seq);
|
||||||
case 'move':
|
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':
|
case 'delete':
|
||||||
await client.uidMarkDeleted(seq);
|
await client.uidMarkDeleted(seq);
|
||||||
await client.uidExpunge(seq);
|
await client.uidExpunge(seq);
|
||||||
@@ -2332,7 +2367,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await client.createMailbox(dest);
|
await client.createMailbox(dest);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await client.uidStore(seq, [keyword], action: imap.StoreAction.add);
|
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':
|
case 'unsnooze':
|
||||||
final dest = payload['dest'] as String;
|
final dest = payload['dest'] as String;
|
||||||
try {
|
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(
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 3),
|
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(
|
content: const Text(
|
||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 3),
|
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(
|
content: const Text(
|
||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
@@ -93,7 +94,9 @@ class UndoLogDetailScreen extends ConsumerWidget {
|
|||||||
style: theme.textTheme.bodySmall,
|
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 {
|
class _EmailTile extends ConsumerWidget {
|
||||||
const _EmailTile({required this.email});
|
const _EmailTile({required this.email, required this.accountId});
|
||||||
|
|
||||||
final Email email;
|
final Email email;
|
||||||
|
final String accountId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sender = email.from.isNotEmpty
|
final sender = email.from.isNotEmpty
|
||||||
? (email.from.first.name ?? email.from.first.email)
|
? (email.from.first.name ?? email.from.first.email)
|
||||||
: '(Unknown Sender)';
|
: '(Unknown Sender)';
|
||||||
@@ -134,6 +138,43 @@ class _EmailTile extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.email_outlined),
|
leading: const Icon(Icons.email_outlined),
|
||||||
title: Text(email.subject ?? '(No Subject)'),
|
title: Text(email.subject ?? '(No Subject)'),
|
||||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
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
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ dependencies:
|
|||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
device_info_plus: ^13.1.0
|
device_info_plus: ^13.1.0
|
||||||
|
|
||||||
|
# @visibleForTesting annotation used in lib/data/repositories.
|
||||||
|
meta: ^1.16.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that the Dagger version is consistent across the project.
|
||||||
|
#
|
||||||
|
# The Dagger CLI must speak the same protocol as the engine it talks to. We
|
||||||
|
# pin the version in four places (engine image in DAGGER.md, the CLI in
|
||||||
|
# flake.nix, the CLI in the Forgejo runner Dockerfile, and the module
|
||||||
|
# engineVersion in ci/dagger.json). This script fails if any of them drift.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
# ci/dagger.json — strip leading "v" for comparison.
|
||||||
|
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
|
||||||
|
| sed -E 's/.*"v?([^"]+)"$/\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 \
|
||||||
|
| cut -d= -f2)
|
||||||
|
|
||||||
|
# DAGGER.md — engine image tag in the example systemd unit.
|
||||||
|
dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
|
||||||
|
| head -n1 \
|
||||||
|
| sed -E 's@.*/v@@')
|
||||||
|
|
||||||
|
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
|
||||||
|
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
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
echo "ERROR: failed to parse a Dagger version reference." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Dagger versions aligned (v$dagger_json)."
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Upload an Android App Bundle to the Google Play Store internal track."""
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -11,7 +17,7 @@ from google.oauth2 import service_account
|
|||||||
|
|
||||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||||
TRACK = "internal"
|
TRACKS = ("internal", "alpha")
|
||||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||||
_MAX_UPLOAD_ATTEMPTS = 3
|
_MAX_UPLOAD_ATTEMPTS = 3
|
||||||
@@ -94,19 +100,20 @@ def main():
|
|||||||
version_code = bundle["versionCode"]
|
version_code = bundle["versionCode"]
|
||||||
print(f"Uploaded AAB, version code: {version_code}")
|
print(f"Uploaded AAB, version code: {version_code}")
|
||||||
|
|
||||||
track_resp = session.put(
|
for track in TRACKS:
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
track_resp = session.put(
|
||||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{track}",
|
||||||
timeout=30,
|
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||||
)
|
timeout=30,
|
||||||
track_resp.raise_for_status()
|
)
|
||||||
|
track_resp.raise_for_status()
|
||||||
|
|
||||||
commit_resp = session.post(
|
commit_resp = session.post(
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
commit_resp.raise_for_status()
|
commit_resp.raise_for_status()
|
||||||
print(f"Deployed version {version_code} to {TRACK} track")
|
print(f"Deployed version {version_code} to tracks: {', '.join(TRACKS)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ class TestMainHappyPath(unittest.TestCase):
|
|||||||
track_call = session.put.call_args_list[0]
|
track_call = session.put.call_args_list[0]
|
||||||
self.assertIn("/tracks/", track_call[0][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):
|
class TestUploadRetry(unittest.TestCase):
|
||||||
def _run_main(self, upload_side_effects, sleep_mock=None):
|
def _run_main(self, upload_side_effects, sleep_mock=None):
|
||||||
|
|||||||
@@ -1109,6 +1109,242 @@ void main() {
|
|||||||
expect(spy.movedToMailbox, 'Snoozed');
|
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', () {
|
group('Snooze', () {
|
||||||
|
|||||||
@@ -19,9 +19,25 @@ class FakeImapClient extends imap.ImapClient {
|
|||||||
|
|
||||||
/// Spy IMAP client that records snooze-related operations and succeeds silently.
|
/// Spy IMAP client that records snooze-related operations and succeeds silently.
|
||||||
class SnoozeSpyImapClient extends FakeImapClient {
|
class SnoozeSpyImapClient extends FakeImapClient {
|
||||||
|
SnoozeSpyImapClient({
|
||||||
|
this.copyUidValidity,
|
||||||
|
this.copyUidSourceToTarget = const {},
|
||||||
|
this.searchResults = const {},
|
||||||
|
});
|
||||||
|
|
||||||
String? selectedMailbox;
|
String? selectedMailbox;
|
||||||
String? createdMailbox;
|
String? createdMailbox;
|
||||||
String? movedToMailbox;
|
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(
|
imap.Mailbox _fakeMailbox(String path) => imap.Mailbox(
|
||||||
encodedName: path,
|
encodedName: path,
|
||||||
@@ -63,7 +79,33 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
|||||||
String? targetMailboxPath,
|
String? targetMailboxPath,
|
||||||
}) async {
|
}) async {
|
||||||
movedToMailbox = targetMailboxPath;
|
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
|
@override
|
||||||
|
|||||||
@@ -582,6 +582,54 @@ void main() {
|
|||||||
|
|
||||||
expect(find.textContaining('Structure not available'), findsOneWidget);
|
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);
|
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