Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b353d1e54 | ||
|
|
bb133b88e1 | ||
|
|
5fc63068b0 | ||
|
|
874d25a40c | ||
|
|
c927c69fe2 | ||
|
|
0e63c5d9c9 | ||
|
|
b6f73624b1 | ||
|
|
2c6df11e4b | ||
|
|
171b399e01 | ||
|
|
540554c111 | ||
|
|
7453484280 | ||
|
|
2441e47390 | ||
|
|
6e23c14132 | ||
|
|
6bfd105ad2 | ||
|
|
f627bfc123 | ||
|
|
01dc322aeb |
+237
@@ -0,0 +1,237 @@
|
|||||||
|
# CI for the self-hosted GitLab that replaced GitHub Actions when the account
|
||||||
|
# was suspended on 2026-09-20. This is a port of .github/workflows/ci.yml and
|
||||||
|
# publish.yml, which are kept in the tree for reference and for the day the
|
||||||
|
# appeal succeeds.
|
||||||
|
#
|
||||||
|
# Every `image:` here is pinned to a digest, with the tag it belonged to in the
|
||||||
|
# trailing comment. That is the direct replacement for the SHA-pinned `uses:`
|
||||||
|
# in the Actions workflows: GitLab has no equivalent of an action allowlist, so
|
||||||
|
# the only thing standing between this pipeline and whatever the publisher
|
||||||
|
# pushes to a tag next is the digest. Read the comment for the version; the
|
||||||
|
# digest is what runs. Do not "simplify" one back to a bare tag.
|
||||||
|
#
|
||||||
|
# The runner is a group runner on Web_Host with the host docker socket bound
|
||||||
|
# in, reached over the internal container network rather than
|
||||||
|
# https://git.coffeylabs.org -- that name is Cloudflare-proxied on the Free
|
||||||
|
# plan, which caps request bodies at 100 MB and would break artifact uploads.
|
||||||
|
|
||||||
|
stages: [test, build, publish, release]
|
||||||
|
|
||||||
|
variables:
|
||||||
|
# Jobs talk to the registry directly on its DNS-only name, never through the
|
||||||
|
# proxy, for the same 100 MB reason.
|
||||||
|
IMAGE: $CI_REGISTRY_IMAGE
|
||||||
|
GIT_DEPTH: "0"
|
||||||
|
|
||||||
|
default:
|
||||||
|
interruptible: true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- test ------
|
||||||
|
node:
|
||||||
|
stage: test
|
||||||
|
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||||
|
variables:
|
||||||
|
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files: [package-lock.json]
|
||||||
|
paths: [.npm/]
|
||||||
|
before_script:
|
||||||
|
# version.test.ts shells out to git to resolve a build version, and the
|
||||||
|
# slim image ships without it. The clone is done by the runner's helper
|
||||||
|
# image, so nothing else here needs git and its absence is easy to miss.
|
||||||
|
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git
|
||||||
|
# config.test.ts chmods a directory to 0555 and expects the write to be
|
||||||
|
# refused. Root ignores the permission bits, so as root that assertion can
|
||||||
|
# never hold. The tests run as the image's unprivileged `node` user for
|
||||||
|
# that reason; -p keeps the environment.
|
||||||
|
#
|
||||||
|
# imageproxy.test.ts needs IPv6 as well, which is not set here but on the
|
||||||
|
# runner: jobs run on the `ci-net` docker network, created with --ipv6.
|
||||||
|
# Without a non-loopback IPv6 address on the container, getaddrinfo's
|
||||||
|
# AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves to
|
||||||
|
# IPv4 only, and the test's control case connects to a port nothing is
|
||||||
|
# listening on. That is a runner property, so it cannot be fixed from this
|
||||||
|
# file -- if these tests ever fail again with ECONNREFUSED on 127.0.0.1,
|
||||||
|
# check that the runner still puts jobs on an IPv6-enabled network.
|
||||||
|
- chown -R node:node "$CI_PROJECT_DIR"
|
||||||
|
script:
|
||||||
|
- su node -p -c "npm ci --ignore-scripts"
|
||||||
|
- su node -p -c "npm run typecheck"
|
||||||
|
- su node -p -c "npm test"
|
||||||
|
- su node -p -c "npm run build"
|
||||||
|
artifacts:
|
||||||
|
paths: [dist/]
|
||||||
|
expire_in: 1 week
|
||||||
|
rules:
|
||||||
|
- if: $RELEASE_WEEKLY == "1"
|
||||||
|
when: never
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- build ------
|
||||||
|
# Proves the Dockerfile still builds on every change, without pushing. The
|
||||||
|
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step.
|
||||||
|
#
|
||||||
|
# Not called `image`: that is a reserved keyword, and a job by that name is
|
||||||
|
# silently read as the global image: setting instead ("image name should be a
|
||||||
|
# string"). Same trap for `stages`, `cache`, `services` and `variables`.
|
||||||
|
docker-build:
|
||||||
|
stage: build
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
needs: [node]
|
||||||
|
script:
|
||||||
|
- docker build -t ihasmail:ci-$CI_COMMIT_SHORT_SHA .
|
||||||
|
- docker image rm ihasmail:ci-$CI_COMMIT_SHORT_SHA
|
||||||
|
rules:
|
||||||
|
- if: $RELEASE_WEEKLY == "1"
|
||||||
|
when: never
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- publish ------
|
||||||
|
# Tag-driven, replacing the release -> publish workflow_call chain. GitHub
|
||||||
|
# needed that dance because a release cut with GITHUB_TOKEN raises no event;
|
||||||
|
# GitLab has no such rule, so a tag pipeline is enough.
|
||||||
|
#
|
||||||
|
# arm64 is built under QEMU on this amd64 host, not on a native runner as
|
||||||
|
# GitHub's free `ubuntu-24.04-arm` did. It is slow -- tens of minutes for the
|
||||||
|
# npm install and Vite build through instruction translation -- which is
|
||||||
|
# tolerable for a weekly tag and would not be for every push. That is why this
|
||||||
|
# job is tag-only. If arm64 ever starts timing out, the fix is an arm64 runner,
|
||||||
|
# not dropping the platform: TrueNAS and Unraid users pull it.
|
||||||
|
# The version the image is built with, computed the way publish.yml did it:
|
||||||
|
# scripts/version.mjs, which needs node and the full history. The build is
|
||||||
|
# *told* the real form (IHASMAIL_VERSION, what About and /api/health report);
|
||||||
|
# the Docker tag gets the same string with '+' turned into '-', because a tag
|
||||||
|
# may not contain '+'. The first port of this job left the build arg out, so
|
||||||
|
# a tag would have shipped an image reporting itself unversioned -- which is
|
||||||
|
# exactly what version.test.ts calls looking wrong.
|
||||||
|
version:
|
||||||
|
stage: build
|
||||||
|
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||||
|
variables:
|
||||||
|
GIT_DEPTH: "0"
|
||||||
|
before_script:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git >/dev/null
|
||||||
|
# The build directory is reused between jobs, and the node job chowns it to
|
||||||
|
# the unprivileged `node` user so its tests can run. A later job running
|
||||||
|
# git as root then finds the checkout owned by somebody else, and git
|
||||||
|
# refuses with "detected dubious ownership" (exit 128). Whether it happens
|
||||||
|
# depends on which cached directory a job lands on, so it comes and goes.
|
||||||
|
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||||
|
script:
|
||||||
|
- V="$(node scripts/version.mjs)"
|
||||||
|
- echo "VERSION=$V" > version.env
|
||||||
|
- echo "DOCKER_TAG=${V/+/-}" >> version.env
|
||||||
|
- cat version.env
|
||||||
|
artifacts:
|
||||||
|
reports:
|
||||||
|
dotenv: version.env
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
|
publish:
|
||||||
|
stage: publish
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
needs: [node, version]
|
||||||
|
variables:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
before_script:
|
||||||
|
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
|
- docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
- docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg IHASMAIL_VERSION="$VERSION" \
|
||||||
|
--provenance=false --sbom=false \
|
||||||
|
--tag "$IMAGE:$DOCKER_TAG" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
--push .
|
||||||
|
after_script:
|
||||||
|
- docker logout "$CI_REGISTRY" || true
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- weekly release --
|
||||||
|
# Port of .github/workflows/release.yml: cut a release once a week, but only
|
||||||
|
# when there is something in it. The decision is the workflow's, unchanged --
|
||||||
|
# count the commits on main since the newest published release, and skip the
|
||||||
|
# week if there are none or if the tag already exists (the version comes from
|
||||||
|
# the commit, so an unchanged commit is an existing tag).
|
||||||
|
#
|
||||||
|
# It runs from a pipeline schedule (Mondays 09:17 UTC, the same odd minute as
|
||||||
|
# before) that sets RELEASE_WEEKLY=1. GitLab keeps schedules on the project,
|
||||||
|
# not in this file, so the schedule and this job only work as a pair. Run it by
|
||||||
|
# hand with RELEASE_WEEKLY=1, adding DRY_RUN=1 to see the decision and stop.
|
||||||
|
#
|
||||||
|
# The release -- and with it the tag -- is created with RELEASE_TOKEN, a
|
||||||
|
# project access token (protected, masked), not CI_JOB_TOKEN. A tag pushed that
|
||||||
|
# way is an ordinary push, so it starts the tag pipeline, and the version and
|
||||||
|
# publish jobs above build the image from it. That replaces release.yml's
|
||||||
|
# direct call of publish.yml, which only existed because a tag created with
|
||||||
|
# GITHUB_TOKEN raises no event. The token expires; when it does this job fails
|
||||||
|
# at the API call, loudly, and a new one goes in the same variable.
|
||||||
|
weekly-release:
|
||||||
|
stage: release
|
||||||
|
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
|
||||||
|
# One at a time: two overlapping runs would race to create the same tag.
|
||||||
|
resource_group: weekly-release
|
||||||
|
variables:
|
||||||
|
GIT_DEPTH: "0"
|
||||||
|
before_script:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl jq >/dev/null
|
||||||
|
# See the version job: same shared directory, same root, same refusal.
|
||||||
|
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
# Internal address, as for everything else CI does: never through the proxy.
|
||||||
|
API="http://gitlab/api/v4/projects/${CI_PROJECT_ID}"
|
||||||
|
auth=(--header "PRIVATE-TOKEN: ${RELEASE_TOKEN}")
|
||||||
|
# The newest published release, or empty on a project that has never had
|
||||||
|
# one -- in which case everything counts as new.
|
||||||
|
previous="$(curl -fsS "${auth[@]}" "${API}/releases?order_by=released_at&sort=desc&per_page=1" | jq -r '.[0].tag_name // ""')"
|
||||||
|
# A release can outlive its tag. Falling back to the whole history
|
||||||
|
# over-counts, which cuts a release that was due anyway; under-counting
|
||||||
|
# would skip one that was.
|
||||||
|
# Tag lookups use show-ref, which matches an exact ref and nothing else.
|
||||||
|
# `rev-parse --verify refs/tags/<name>` does not: on the git in this image
|
||||||
|
# (2.39) a name ending in -g<hex> falls back to being read as
|
||||||
|
# git-describe output, resolves to that commit, and so "exists" whether
|
||||||
|
# or not the tag does. Every commit not merged through a pull request has
|
||||||
|
# a -g<hex> version, so that check reported every such week as already
|
||||||
|
# released. Newer git (and GitHub's runners) do not fall back, which is
|
||||||
|
# why release.yml never showed it.
|
||||||
|
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
|
||||||
|
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
|
||||||
|
else
|
||||||
|
count="$(git rev-list --count HEAD)"; range="HEAD"
|
||||||
|
fi
|
||||||
|
version="$(node scripts/version.mjs)"
|
||||||
|
# A Docker tag may not contain '+', and neither should the git tag, so
|
||||||
|
# the two always agree about what to call a build.
|
||||||
|
tag="v${version/+/-}"
|
||||||
|
title="v${version%%+*}"
|
||||||
|
sha="$(git rev-parse HEAD)"
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
echo "Nothing to release: no commits since ${previous}."; exit 0
|
||||||
|
fi
|
||||||
|
if git show-ref --verify --quiet "refs/tags/${tag}"; then
|
||||||
|
echo "Nothing to release: tag ${tag} already exists."; exit 0
|
||||||
|
fi
|
||||||
|
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, at ${sha}."
|
||||||
|
if [ "${DRY_RUN:-0}" = "1" ]; then echo "DRY_RUN=1: stopping here."; exit 0; fi
|
||||||
|
# Notes bounded to what is new, from the first-parent history of main --
|
||||||
|
# one line per merge, which is what GitHub's generated notes listed.
|
||||||
|
notes="$(git log --first-parent --format='- %s' "$range")"
|
||||||
|
jq -n --arg tag "$tag" --arg ref "$sha" --arg name "$title" \
|
||||||
|
--arg desc "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
|
||||||
|
'{tag_name:$tag, ref:$ref, name:$name, description:$desc}' > release.json
|
||||||
|
curl -fsS "${auth[@]}" --header "Content-Type: application/json" \
|
||||||
|
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
|
||||||
|
rules:
|
||||||
|
- if: $RELEASE_WEEKLY == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
@@ -77,7 +77,7 @@ docker compose up --build -d
|
|||||||
# → http://localhost:8080 — put a reverse proxy in front for TLS
|
# → http://localhost:8080 — put a reverse proxy in front for TLS
|
||||||
```
|
```
|
||||||
|
|
||||||
Or pull the published image, `ghcr.io/coffey-labs/ihasmail`. Releases are
|
Or pull the published image, `registry.coffeylabs.org/coffey-labs/ihasmail`. Releases are
|
||||||
weekly, so it is usually a few days behind `main`.
|
weekly, so it is usually a few days behind `main`.
|
||||||
|
|
||||||
People sign in with their Stalwart mailbox credentials. **An account with
|
People sign in with their Stalwart mailbox credentials. **An account with
|
||||||
|
|||||||
@@ -153,6 +153,30 @@ describe("a message of mine with nobody obvious to reply to", () => {
|
|||||||
const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply");
|
const d = await draftFor({ ...MINE, to: [ME], cc: [] } as Email, "reply");
|
||||||
expect(addrs(d.to)).toEqual([ME.email]);
|
expect(addrs(d.to)).toEqual([ME.email]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers the Reply-To rather than my own desk when nobody else is on it", async () => {
|
||||||
|
/*
|
||||||
|
* A contact form: the site mails itself, From and To both its own address,
|
||||||
|
* and the person who filled the form in is in Reply-To. From alone makes
|
||||||
|
* this look like mine, and the fallback used to reply to me (#415).
|
||||||
|
*/
|
||||||
|
const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "[email protected]" }] } as Email;
|
||||||
|
const d = await draftFor(form, "reply");
|
||||||
|
expect(addrs(d.to)).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does the same on a reply all, without cc-ing myself", async () => {
|
||||||
|
const form = { ...MINE, to: [ME], cc: [], replyTo: [{ name: "Michael", email: "[email protected]" }] } as Email;
|
||||||
|
const d = await draftFor(form, "replyAll");
|
||||||
|
expect(addrs(d.to)).toEqual(["[email protected]"]);
|
||||||
|
expect(d.cc).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still prefers somebody I actually wrote to over my own Reply-To", async () => {
|
||||||
|
// The Cc is a person; the Reply-To is where answers to me belong.
|
||||||
|
const d = await draftFor({ ...MINE, to: [ME], replyTo: [{ name: null, email: "[email protected]" }] } as Email, "reply");
|
||||||
|
expect(addrs(d.to)).toEqual([BOB.email]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("forwarding", () => {
|
describe("forwarding", () => {
|
||||||
|
|||||||
@@ -410,6 +410,19 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
// Addressed only to myself, or only in Cc: there is still somebody this
|
// Addressed only to myself, or only in Cc: there is still somebody this
|
||||||
// is a reply to, and an empty To is not it.
|
// is a reply to, and an empty To is not it.
|
||||||
if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; }
|
if (!to.length) { to = cc.length ? cc : withoutOwn(full.cc ?? []); cc = []; }
|
||||||
|
/*
|
||||||
|
* Nobody but me on the message, and a Reply-To pointing somewhere that
|
||||||
|
* is not mine: that address is who this is really from.
|
||||||
|
*
|
||||||
|
* A contact form is the shape of it -- From and To are both the site's
|
||||||
|
* own mailbox, and the person who filled the form in is in Reply-To.
|
||||||
|
* The address test above calls that mine, correctly as far as it goes,
|
||||||
|
* and the fallback then addressed the reply to my own desk (#415).
|
||||||
|
*
|
||||||
|
* After the Cc, not before it: a message I really did send carries my
|
||||||
|
* own Reply-To, and somebody I actually wrote to beats it.
|
||||||
|
*/
|
||||||
|
if (!to.length) to = withoutOwn(full.replyTo ?? []);
|
||||||
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
|
if (!to.length) to = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]);
|
||||||
} else {
|
} else {
|
||||||
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
|
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
|
||||||
|
|||||||
@@ -224,7 +224,23 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
}, [messages, reply]);
|
}, [messages, reply]);
|
||||||
|
|
||||||
const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)";
|
const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)";
|
||||||
const rowIds = thread ? thread.emailIds.filter((id) => emails[id]) : [];
|
/*
|
||||||
|
* What the toolbar acts on: the messages the pane is showing, not the thread
|
||||||
|
* they belong to.
|
||||||
|
*
|
||||||
|
* With conversation view off, opening a message opens that message -- the
|
||||||
|
* list shows it alone, the pane renders it alone, and the buttons above it
|
||||||
|
* said so, because `anyUnread` and the rest already read `messages`. Only the
|
||||||
|
* ids handed to the action still named the whole thread, so Mark as unread,
|
||||||
|
* Move to, Report spam and Delete quietly took every message in it (#414).
|
||||||
|
*
|
||||||
|
* Same fallback as the pane's: an id naming nothing in this thread means the
|
||||||
|
* whole conversation, so the buttons keep matching what is on screen.
|
||||||
|
*/
|
||||||
|
const rowIds = useMemo(() => {
|
||||||
|
const loaded = thread ? thread.emailIds.filter((id) => emails[id]).map((id) => ({ id })) : [];
|
||||||
|
return visibleMessages(loaded, messageId).map((m) => m.id);
|
||||||
|
}, [thread, emails, messageId]);
|
||||||
const anyUnread = messages.some((e) => !e.keywords.$seen);
|
const anyUnread = messages.some((e) => !e.keywords.$seen);
|
||||||
const anyStarred = messages.some((e) => e.keywords.$flagged);
|
const anyStarred = messages.some((e) => e.keywords.$flagged);
|
||||||
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
|
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ThreadView } from "../ThreadView";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import type { ListActions } from "../MessageList";
|
||||||
|
import type { Email, Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
/* jsdom has neither of these, and the opening scroll uses both. */
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
/* jsdom has no matchMedia, and the toolbar asks whether this is a phone. */
|
||||||
|
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reported from the inbox with conversation view off: marking a message unread
|
||||||
|
* from the list -- hover button, right-click menu -- touched that message, but
|
||||||
|
* the same action from the toolbar above the *opened* message marked every
|
||||||
|
* message in its thread. Move to, Report spam and Delete did it too (#414).
|
||||||
|
*
|
||||||
|
* The toolbar's labels were already right: "Mark as unread" read the messages
|
||||||
|
* on screen. Only the ids it handed the action named the whole thread.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const msg = (id: Id, subject: string): Email =>
|
||||||
|
({
|
||||||
|
id, threadId: "t1", subject, mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||||
|
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "Me", email: "[email protected]" }],
|
||||||
|
receivedAt: "2026-09-20T10:00:00Z", size: 10, blobId: "b1", preview: "hi",
|
||||||
|
htmlBody: [], textBody: [{ partId: "1", type: "text/plain" }],
|
||||||
|
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||||
|
attachments: [],
|
||||||
|
}) as unknown as Email;
|
||||||
|
|
||||||
|
const FIRST = msg("m1", "The question");
|
||||||
|
const SECOND = msg("m2", "Re: The question");
|
||||||
|
|
||||||
|
function stubStore() {
|
||||||
|
useMail.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
threads: { t1: { id: "t1", emailIds: ["m1", "m2"] } } as never,
|
||||||
|
emails: { m1: FIRST, m2: SECOND } as never,
|
||||||
|
fullIds: { m1: true, m2: true } as never,
|
||||||
|
loadingThreads: {} as never,
|
||||||
|
mailboxes: { inbox: { id: "inbox", name: "Inbox", role: "inbox" } } as never,
|
||||||
|
loadThread: (async () => undefined) as never,
|
||||||
|
setOpenThread: (() => undefined) as never,
|
||||||
|
markRead: (async () => undefined) as never,
|
||||||
|
roleId: (() => null) as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("what the toolbar above an opened message acts on", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let actions: ListActions;
|
||||||
|
|
||||||
|
const show = async (messageId: Id | null) => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ThreadView
|
||||||
|
threadId="t1" mailboxId="inbox" messageId={messageId} actions={actions}
|
||||||
|
onBack={() => undefined} onNavigate={() => undefined} hasPrev={false} hasNext={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The toolbar buttons carry their shortcut in the title, as the tooltips show. */
|
||||||
|
const press = async (title: string) => {
|
||||||
|
const btn = [...host.querySelectorAll("button")].find((b) => b.title === title);
|
||||||
|
expect(btn, `no toolbar button titled ${title}`).toBeTruthy();
|
||||||
|
await act(async () => btn!.click());
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubStore();
|
||||||
|
actions = {
|
||||||
|
archive: vi.fn(async () => undefined), trash: vi.fn(async () => undefined),
|
||||||
|
spam: vi.fn(async () => undefined), read: vi.fn(async () => undefined),
|
||||||
|
star: vi.fn(async () => undefined), move: vi.fn(async () => undefined),
|
||||||
|
label: vi.fn(async () => undefined),
|
||||||
|
} as unknown as ListActions;
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the message that is open, not its thread", async () => {
|
||||||
|
await show("m1");
|
||||||
|
await press("Mark as unread");
|
||||||
|
expect(actions.read).toHaveBeenCalledWith(false, ["m1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves, reports and deletes only that message too", async () => {
|
||||||
|
await show("m1");
|
||||||
|
await press("Move to (v)");
|
||||||
|
await press("Report spam (!)");
|
||||||
|
await press("Delete (#)");
|
||||||
|
expect(actions.move).toHaveBeenCalledWith(["m1"]);
|
||||||
|
expect(actions.spam).toHaveBeenCalledWith(["m1"]);
|
||||||
|
expect(actions.trash).toHaveBeenCalledWith(["m1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the whole thread when the pane is showing the whole thread", async () => {
|
||||||
|
// Conversation view on: no message singled out, and the toolbar is the
|
||||||
|
// conversation's toolbar. That is the behaviour this must not disturb.
|
||||||
|
await show(null);
|
||||||
|
await press("Mark as unread");
|
||||||
|
expect(actions.read).toHaveBeenCalledWith(false, ["m1", "m2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the thread when the open id names nothing in it", async () => {
|
||||||
|
// A link from somebody with conversation view on, or a stale `m` in the
|
||||||
|
// URL. The pane shows the conversation, so the toolbar acts on it.
|
||||||
|
await show("gone");
|
||||||
|
await press("Mark as unread");
|
||||||
|
expect(actions.read).toHaveBeenCalledWith(false, ["m1", "m2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user