Compare commits

..
Author SHA1 Message Date
jcoffey-dev 4a032ceed7 Merge branch 'ci/gitea-actions' into 'main'
ci / version (push) Skipped
ci / node (push) Successful in 5m38s
ci / publish (push) Skipped
ci / docker-build (push) Successful in 3m3s
ci: add Gitea Actions workflow

See merge request coffey-labs/ihasmail!10
2026-09-22 00:52:10 -07:00
jcoffey-dev d7be002c19 ci: fail clearly when PACKAGE_TOKEN is missing
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 6m12s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 2m51s
ci / version (push) Successful in 2m31s
ci / node (push) Successful in 2m59s
ci / docker-build (push) Skipped
ci / publish (push) Successful in 8m0s
2026-09-21 23:20:47 -07:00
jcoffey-dev 67aab8015a ci: add Gitea Actions workflows ported from .gitlab-ci.yml
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 4m46s
ci / publish (pull_request) Skipped
ci / docker-build (pull_request) Successful in 3m19s
ci / version (push) Successful in 2m23s
ci / node (push) Successful in 2m51s
ci / docker-build (push) Skipped
ci / publish (push) Failing after 32s
2026-09-21 22:48:55 -07:00
jcoffey-dev 6484645a04 Merge branch 'ci/registry-token-host' into 'main'
Fetch the registry token from the public address, not the runner's

See merge request coffey-labs/ihasmail!9
2026-09-21 18:20:47 -07:00
jcoffey-dev 795fe43cec Fetch the registry token from the public address, not the runner's
The builder on the host's network (the last change here) didn't help: the
next publish failed exactly as before. Looking on the host showed why.
Both builders resolve git.coffeylabs.org publicly; the token isn't fetched
by the builder at all. buildx fetches registry tokens on the client side,
in the job container, and on ci-net the name git.coffeylabs.org belongs to
the gitlab container itself (172.30.0.2) -- which is how the runner clones
over plain HTTP, and which has nothing on 443. So every push asked
https://git.coffeylabs.org/jwt/auth for a token and was refused. The login
before it worked because the host's daemon does the login, and the host
resolves the name publicly.

For the publish job only, the name now points at its public address in the
job's /etc/hosts, looked up from a public resolver, as the host sees it.
/etc/hosts wins over Docker's DNS, and nothing else in the job is affected:
the checkout is done, and image layers go to the registry's own DNS-only
name, not this one. The builder goes back to the shared ci-builder; its
network was never the problem.

The lookup and the /etc/hosts write were tried in the job's own image
(docker:28-cli, same digest): it picks the first public IPv4 address and
getent then returns it.
2026-09-21 16:48:47 -07:00
jcoffey-dev 31ab2284ed Merge branch 'ci/buildx-host-network' into 'main'
Publish with a builder on the host's network

See merge request coffey-labs/ihasmail!8
2026-09-21 16:34:14 -07:00
jcoffey-dev 8acb1b66ad Publish with a builder on the host's network
The v2026.9.20 publish (job 513) built both platforms, then failed to
push:

  failed to fetch oauth token: Post "https://git.coffeylabs.org/jwt/auth":
  dial tcp 172.30.0.2:443: connect: connection refused

buildx's docker-container builder is a container of its own on the host's
daemon, and it does the push, token and all. On the network it was
created on, git.coffeylabs.org resolves to an internal address with
nothing listening on 443. The job's own `docker login` worked because it
goes through the host daemon. inbuxa-admin's first release failed the
same way.

The builder now runs on the host's network, so it resolves the name as
the login does. Only the token request goes to git.coffeylabs.org; image
layers still go to registry.coffeylabs.org, the registry's DNS-only name.
It gets a new name, ci-builder-host: `ci-builder` is a long-lived
container shared between jobs, and `create || use` would keep reusing it
on its old network.
2026-09-21 16:31:15 -07:00
jcoffey-dev e9ff2a1e9c Merge branch 'fix/move-picker-folder-order' into 'main'
List folders in sidebar order in the move-to picker

Closes #1

See merge request coffey-labs/ihasmail!7
2026-09-21 09:07:13 -07:00
jcoffey-dev ea03406646 List folders in sidebar order in the move-to picker
The picker sorted folders A-Z by path, with Inbox first, so a folder
dragged into place in the sidebar turned up somewhere else when moving
mail. It now walks the tree in compareFolders order, the sidebar's
order with every folder expanded: Inbox, then the saved order, then
the special folders, then A-Z, with subfolders under their parent.

treeOrder lives beside compareFolders. A folder the walk from the top
cannot reach is appended rather than dropped, so it stays pickable as
it was before.

Closes #1
2026-09-21 08:25:58 -07:00
jcoffey-dev 5b353d1e54 Merge branch 'ci/safe-directory' into 'main'
Let root jobs use a checkout the node job chowned

See merge request coffey-labs/ihasmail!6
2026-09-20 23:31:59 -07:00
jcoffey-dev bb133b88e1 Let root jobs use a checkout the node job chowned
The build directory is reused between jobs, and the node job chowns it to
the unprivileged node user for its tests. A later job running git as root
then finds the checkout owned by someone else and git refuses with
"detected dubious ownership" (exit 128). Which cached directory a job lands
on decides whether it happens, so it is intermittent: the first weekly
release dry run passed and the second failed.

The version job in the tag pipeline runs git as root too, so the same
refusal would have stopped a release from ever publishing its image. Both
jobs now mark the project directory safe before touching git.
2026-09-20 23:29:19 -07:00
jcoffey-dev 5fc63068b0 Merge branch 'ci/weekly-release-tagcheck' into 'main'
Look tags up by exact ref in the weekly release

See merge request coffey-labs/ihasmail!5
2026-09-20 23:23:42 -07:00
jcoffey-dev 874d25a40c Look tags up by exact ref in the weekly release
The dry run reported tag v2026.9.20-gc927c69 as existing when it did not.
On the git in the job image (2.39), rev-parse --verify refs/tags/<name>
falls back to reading a name ending in -g<hex> as git-describe output, and
resolves it to that commit. Every commit not merged through a pull request
gets a -g<hex> version, so every such week would have been skipped as
already released -- silently, since skipping is a normal outcome.

show-ref --verify matches an exact ref and nothing else. Both tag checks
use it now. release.yml has the same code; it only worked because GitHub's
runners carry a newer git that does not fall back.
2026-09-20 23:20:59 -07:00
jcoffey-dev c927c69fe2 Merge branch 'ci/weekly-release' into 'main'
Cut the weekly release on GitLab

See merge request coffey-labs/ihasmail!4
2026-09-20 23:16:36 -07:00
jcoffey-dev 0e63c5d9c9 Cut the weekly release on GitLab
release.yml stopped running with the GitHub account, and nothing replaced
it: no tag has been cut since, so the publish job had nothing to build.

This ports its decision unchanged -- release only when main has commits
since the newest published release, and only if the tag does not already
exist -- to a job run by a Monday 09:17 UTC pipeline schedule. The schedule
lives on the project and sets RELEASE_WEEKLY=1; DRY_RUN=1 stops after the
decision.

The release, and so the tag, is created with a project access token rather
than the job token. That makes the tag an ordinary push, which starts the
tag pipeline and its publish job, replacing release.yml's direct call of
publish.yml. The checks are skipped in the release pipeline, as they were
on GitHub: main has already passed them.
2026-09-20 23:14:17 -07:00
jcoffey-dev b6f73624b1 Merge branch 'images/registry' into 'main'
Point image references at the new registry

See merge request coffey-labs/ihasmail!3
2026-09-20 22:55:24 -07:00
jcoffey-dev 2c6df11e4b Point image references at the new registry
ghcr.io went dark with the GitHub account, so every `docker pull` and
template that named it has been failing. The images are republished,
multi-arch as before, at registry.coffeylabs.org under the same names, and
pull anonymously -- nothing needs a login.

Where a link pointed at an issue list, it now goes to /-/work_items: in
GitLab 19 that is the public list, and /-/issues returns 404 to anyone not
signed in. Issues themselves did not come across from GitHub, so a link to
a specific old issue is replaced with a note saying where it was.
2026-09-20 22:49:00 -07:00
jcoffey-dev 171b399e01 Merge branch 'ci/image-version' into 'main'
Build published images with the version they report

See merge request coffey-labs/ihasmail!2
2026-09-20 22:27:14 -07:00
jcoffey-dev 540554c111 Build published images with the version they report
publish.yml passed the computed version into the image build, and the
first port of it to GitLab CI did not. A tag pushed with that port would
have shipped an image reporting itself unversioned (or, for ihasvpn, with a
stray leading "v" no earlier build had), and tagged it with the git tag
rather than the version string.

The version is now computed the way publish.yml computed it and passed as
the build arg, and the image is tagged with it, '+' turned into '-' where a
Docker tag needs that.
2026-09-20 22:19:17 -07:00
jcoffey-dev 7453484280 Merge branch 'ci/gitlab-pipeline' into 'main'
Run CI on the self-hosted GitLab

See merge request coffey-labs/ihasmail!1
2026-09-20 20:04:16 -07:00
jcoffey-dev 2441e47390 Give CI jobs IPv6 rather than a Node flag that did not help
The proxy test failed with ECONNREFUSED on 127.0.0.1 for a server bound
to ::1. That is not resolution order, so --dns-result-order was treating
the wrong cause and is removed: with no non-loopback IPv6 address on the
container, getaddrinfo's AI_ADDRCONFIG drops ::1 from the results
altogether and localhost can only ever come back IPv4.

The runner now puts jobs on a docker network created with --ipv6, which
is where the fix belongs. Verified by reproducing the failure on the old
network and watching it pass on the new one.
2026-09-20 20:00:31 -07:00
jcoffey-dev 6e23c14132 Make the CI job environment match what the tests assume
Three tests failed on the runner and pass locally, all because the job
container differs from a workstation rather than because anything
regressed: config.test.ts chmods a directory and expects the write to be
refused, which root ignores; imageproxy.test.ts binds to ::1 and asks for
localhost, which resolves to IPv4 first here; and version.test.ts shells
out to git, which the slim image does not ship.

So the job installs git, runs the suite as the image's unprivileged node
user, and asks Node for the address order the proxy test was written
against. No test changed.
2026-09-20 19:55:43 -07:00
jcoffey-dev 6bfd105ad2 Run CI on the self-hosted GitLab
GitHub Actions stopped being reachable when the account was suspended, so
this ports ci.yml and publish.yml to a .gitlab-ci.yml running on a group
runner on Web_Host. The Actions workflows stay in the tree: they are the
reference this was written from, and they work again unchanged if the
appeal succeeds.

Two differences worth knowing. Images are pinned by digest rather than the
workflows' SHA-pinned actions, because GitLab has no action allowlist to
back a tag with. And arm64 is built under QEMU instead of on a native
runner, which is slow enough that publish is tag-only.
2026-09-20 19:37:18 -07:00
jcoffey f627bfc123 The toolbar above an open message acts on that message (#414) (#417)
With conversation view off, marking a message unread from the list --
the hover button, the right-click menu -- marked that message. Opening
it and pressing Mark as unread in the toolbar above it marked every
message in its thread, and so did Move to, Report spam and Delete.

The setting already reaches all the way into the reading pane: the list
draws one row per message, and `visibleMessages` narrows the pane to the
one opened. The toolbar was half converted. Its labels were right --
Mark as unread against Mark as read, the star, the labels shown -- all
of those read `messages`, which is the narrowed set. Only `rowIds`, the
one thing actually handed to the action, still read `thread.emailIds`.
So the button said one message and did the whole conversation.

`rowIds` is now the same question `visibleMessages` answers for the
pane, asked of the same ids, with the same fallback: an id that names
nothing in the thread -- a link from somebody with conversation view on,
a stale `m` in the URL -- shows the conversation, so the toolbar takes
the conversation. Conversation view on is unchanged: nothing is singled
out, so the whole thread comes back as before.

No new strings.
2026-09-20 14:53:45 -07:00
jcoffey 01dc322aeb A reply to a self-addressed message follows its Reply-To (#415) (#416)
A website contact form mails the site's own address: From and To are
both info@thesite, and the person who filled the form in is in Reply-To.
Replying addressed the draft to info@thesite -- the site's own desk --
instead of to them.

The reply already knows two shapes. A message somebody sent me is
answered to its Reply-To, which is what that header is for. A message
*I* sent is answered to the people I wrote to, and deliberately not to
my own Reply-To, which is where answers to me belong and would send my
reply to myself. A contact form passes the test for the second: every
address in From is mine.

So it fell down the chain the second shape keeps for a message with
nobody obvious to answer -- To without me, then Cc, then, having run
out, every address on the message, which here was mine alone.

The Reply-To now goes in that chain, one step before the last: when no
recipient but me is left and the message names a Reply-To that is not
mine either, that address is who it is really from. Keeping it after the
Cc is what leaves a message I did send alone -- somebody I actually
wrote to still beats my own Reply-To, which is the case the existing
guard was built for and its test still holds.

No new strings.
2026-09-20 14:50:48 -07:00
12 changed files with 823 additions and 6 deletions
+170
View File
@@ -0,0 +1,170 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub.
#
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in. Read the comment for the version; the digest is
# what runs. Do not "simplify" one back to a bare tag.
#
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
# address, never through the Cloudflare-proxied public name, which caps
# request bodies at 100 MB. Images go to the registry's own DNS-only name
# (vars.REGISTRY, an org variable).
#
# The weekly release is its own workflow, weekly-release.yml.
name: ci
on:
push:
branches: [main]
tags: ['**']
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------- test ------
node:
runs-on: docker
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
env:
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
steps:
# version.test.ts shells out to git to resolve a build version, and the
# slim image ships without it; the checkout action installs it when it
# is missing, so it is there for the tests too. Full history, because
# the version is computed from it.
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
# 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.
- run: chown -R node:node "$GITHUB_WORKSPACE"
- run: su node -p -c "npm ci --ignore-scripts"
- run: su node -p -c "npm run typecheck"
- run: su node -p -c "npm test"
- run: su node -p -c "npm run build"
# ------------------------------------------------------------- build ------
# Proves the Dockerfile still builds on every change, without pushing. The
# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The
# Dockerfile builds everything itself, so nothing is handed over from the
# node job; `needs` only keeps the order.
docker-build:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: [node]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
tag="ihasmail:ci-$(echo "$GITHUB_SHA" | cut -c1-8)"
docker build -t "$tag" .
docker image rm "$tag"
# ----------------------------------------------------------- publish ------
# Tag-driven. GitHub needed a release -> publish workflow_call chain because
# a release cut with GITHUB_TOKEN raises no event -- and Gitea behaves the
# same way, which is why weekly-release.yml cuts its release with
# RELEASE_TOKEN: a tag made with that token is an ordinary push, and starts
# this workflow.
#
# 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 '+'. Leaving the build arg out would ship an
# image reporting itself unversioned -- which is exactly what
# version.test.ts calls looking wrong.
version:
if: ${{ startsWith(github.ref, 'refs/tags/') }}
runs-on: docker
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
outputs:
version: ${{ steps.v.outputs.VERSION }}
docker_tag: ${{ steps.v.outputs.DOCKER_TAG }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- id: v
shell: bash
run: |
V="$(node scripts/version.mjs)"
echo "VERSION=$V" >> "$GITHUB_OUTPUT"
echo "DOCKER_TAG=${V/+/-}" >> "$GITHUB_OUTPUT"
echo "VERSION=$V DOCKER_TAG=${V/+/-}"
# 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 push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): Gitea's
# per-job token is refused by the container registry. The registry hands out
# its push tokens from its own name, so unlike on GitLab nothing here has to
# be pointed at a public address.
publish:
if: ${{ startsWith(github.ref, 'refs/tags/') }}
needs: [node, version]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
DOCKER_TAG: ${{ needs.version.outputs.docker_tag }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
test -n "$REGISTRY" && test -n "$VERSION" && test -n "$DOCKER_TAG"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
- run: |
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 .
docker buildx imagetools inspect "$IMAGE:$DOCKER_TAG"
# Gitea keeps a container package on its owner; linking it shows it on
# the repository's Packages tab. Idempotent.
- run: |
apk add --no-cache -q curl
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|| echo "package already linked (or link refused); not fatal"
- if: always()
run: docker logout "$REGISTRY" || true
+101
View File
@@ -0,0 +1,101 @@
# Weekly release, ported from the weekly-release job in .gitlab-ci.yml (itself
# a port of .github/workflows/release.yml): cut a release once a week, but
# only when there is something in it. The decision is 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).
#
# Mondays 09:17 UTC, the same odd minute as before. Run it by hand from the
# Actions tab (workflow_dispatch); dry_run defaults to true, so a manual run
# shows the decision and stops unless you untick it.
#
# SIDE-BY-SIDE PERIOD: until the GitLab cutover, GitLab's own schedule is
# still live and still cuts the real release, and its tags reach this copy
# through the sync. Two releasers would race to create the same tag, so this
# workflow only ever dry-runs unless the variable RELEASE_LIVE is '1'. Set
# RELEASE_LIVE=1 (repo or org Actions variable) at cutover, when GitLab's
# schedule is switched off -- not before.
#
# Reads use the job's own token. The release -- and with it the tag -- is
# created with RELEASE_TOKEN (jcoffey-dev, write:repository), because a tag
# Gitea creates for the job token raises no event (checked 2026-09-22), and
# the tag has to start ci.yml's version and publish jobs.
name: weekly-release
on:
schedule:
- cron: '17 9 * * 1'
workflow_dispatch:
inputs:
dry_run:
description: Show the decision and stop
type: boolean
default: true
# One at a time: two overlapping runs would race to create the same tag.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
weekly-release:
runs-on: docker
container:
image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim
env:
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
# with dry_run unticked.
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends curl jq >/dev/null
- shell: bash
run: |
set -euo pipefail
# Internal address, as for everything else CI does: never through the proxy.
API="${CI_SERVER_INTERNAL}/api/v1/repos/${GITHUB_REPOSITORY}"
# The newest published release, or empty on a project that has never
# had one -- in which case everything counts as new.
previous="$(curl -fsS -H "Authorization: token ${READ_TOKEN}" "${API}/releases?draft=false&pre-release=false&limit=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.
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" = "1" ]; then echo "Dry run (RELEASE_LIVE='${{ vars.RELEASE_LIVE }}'): 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 body "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
'{tag_name:$tag, target_commitish:$ref, name:$name, body:$body}' > release.json
curl -fsS -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" \
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
+250
View File
@@ -0,0 +1,250 @@
# 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
# The registry hands out push tokens from https://git.coffeylabs.org/jwt/auth,
# and buildx fetches them here, in the job, not in its builder. On ci-net
# that name is the gitlab container itself (172.30.0.2), which serves
# plain HTTP to the runner and nothing on 443, so every push failed at the
# last step with "connection refused". The login above works because the
# host's daemon does it, and the host resolves the name publicly. So, for
# this job only, point the name at its public address the same way. Only
# the token request uses it; layers go to the registry's own DNS-only name.
- |
public="$(nslookup "$CI_SERVER_HOST" 1.1.1.1 2>/dev/null | awk '/^Address: / && $2 !~ /:/ { print $2; exit }')"
if [ -z "$public" ]; then echo "Could not resolve $CI_SERVER_HOST publicly" >&2; exit 1; fi
echo "$public $CI_SERVER_HOST" >> /etc/hosts
echo "$CI_SERVER_HOST -> $public for the registry token"
- 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
+1 -1
View File
@@ -77,7 +77,7 @@ docker compose up --build -d
# → 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`.
People sign in with their Stalwart mailbox credentials. **An account with
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf, treeOrder } from "../folderOrder";
import type { Id, Mailbox } from "@/jmap/types";
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
@@ -119,3 +119,23 @@ describe("neighbour", () => {
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
});
});
describe("treeOrder", () => {
const ids = (all: Record<Id, Mailbox>) => treeOrder(all).map((m) => m.id);
it("lists the tree the way the sidebar does, each folder followed by its subfolders", () => {
expect(ids(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "clients", "zeta"]);
});
it("follows a saved order rather than AZ", () => {
// #1 on GitLab: the move-to picker kept the old order after the sidebar changed.
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(ids(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work", "clients"]);
});
it("still lists a folder the walk from the top can't reach", () => {
const looped = apply(fresh, { work: { parentId: "clients" } });
expect(ids(looped)).toHaveLength(Object.keys(looped).length);
expect(ids(looped)).toEqual(expect.arrayContaining(["work", "clients"]));
});
});
+31
View File
@@ -25,6 +25,37 @@ function roleRank(m: Mailbox): number {
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
}
/**
* Every folder, parents before their children and siblings in
* `compareFolders` order: the sidebar's order with every folder expanded.
* Lists that show all folders at once, like the move-to picker, use this so a
* folder sits where the user dragged it rather than where AZ would put it.
*
* A folder the walk from the top never reaches (a parent loop the server
* should not allow) is appended rather than dropped, so it can still be
* picked.
*/
export function treeOrder(mailboxes: Record<Id, Mailbox>): Mailbox[] {
const byParent = new Map<Id | null, Mailbox[]>();
for (const m of Object.values(mailboxes)) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]);
}
for (const list of byParent.values()) list.sort(compareFolders);
const out: Mailbox[] = [];
const seen = new Set<Id>();
const walk = (parent: Id | null) => {
for (const m of byParent.get(parent) ?? []) {
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push(m);
walk(m.id);
}
};
walk(null);
return out.concat(Object.values(mailboxes).filter((m) => !seen.has(m.id)).sort(compareFolders));
}
/** Every folder under `parentId` (null: the top level), in list order. */
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
return Object.values(mailboxes)
@@ -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");
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", () => {
+13
View File
@@ -410,6 +410,19 @@ export const useCompose = create<ComposeState>((set, get) => ({
// Addressed only to myself, or only in Cc: there is still somebody this
// is a reply to, and an empty To is not it.
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 ?? [])]);
} else {
to = uniqueAddresses(full.replyTo?.length ? full.replyTo : (full.from ?? []));
+5 -3
View File
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
import { treeOrder } from "@/lib/mailbox/folderOrder";
/**
* @param need which right a folder has to grant to be worth offering.
@@ -24,10 +25,11 @@ export function MailboxPicker({ title, onClose, onPick, exclude, need = "mayAddI
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const list = useMemo(() => {
const all = Object.values(mailboxes)
// The sidebar's order, not AZ by path: a folder dragged into place has to
// be found in the same place here.
const all = treeOrder(mailboxes)
.filter((m) => !exclude?.includes(m.id) && m.myRights[need] && (!allow || allow(m.id)))
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes), pick: () => onPick(m.id) }))
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes), pick: () => onPick(m.id) }));
const rows: { m: Mailbox | null; path: string; pick: () => void }[] = root ? [{ m: null, path: root.label, pick: root.onPick }, ...all] : all;
const ql = q.trim().toLowerCase();
return ql ? rows.filter((x) => x.path.toLowerCase().includes(ql)) : rows;
+17 -1
View File
@@ -224,7 +224,23 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
}, [messages, reply]);
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 anyStarred = messages.some((e) => e.keywords.$flagged);
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
@@ -0,0 +1,62 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MailboxPicker } from "../MailboxPicker";
import { useMail } from "@/store/mail";
import type { Mailbox, MailboxRole } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* The move-to picker (v) lists folders in the sidebar's order (#1 on GitLab).
*
* It used to sort AZ by path, so a folder dragged into place in the sidebar
* turned up somewhere else here. The ordering has its own tests in
* lib/mailbox; these check what the dialog actually shows.
*/
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
const box = (id: string, name: string, parentId: string | null, role: MailboxRole = null, sortOrder = 0): Mailbox => ({
id, name, parentId, role, sortOrder, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: rights, isSubscribed: true,
});
/** Ordered by hand in the sidebar: Zeta dragged to the top, Alpha to the bottom. */
const MAILBOXES = {
inbox: box("inbox", "Inbox", null, "inbox", 10),
zeta: box("zeta", "Zeta", null, null, 20),
sent: box("sent", "Sent", null, "sent", 30),
work: box("work", "Work", null, null, 40),
clients: box("clients", "Clients", "work"),
trash: box("trash", "Deleted Items", null, "trash", 50),
alpha: box("alpha", "Alpha", null, null, 60),
};
describe("the move-to picker", () => {
let host: HTMLDivElement;
let root: Root;
const rows = () => Array.from(document.querySelectorAll('[role="option"]')).map((r) => r.querySelector(".grow")?.textContent);
function open(props: Partial<Parameters<typeof MailboxPicker>[0]> = {}) {
act(() => root.render(<MailboxPicker title="Move to…" onClose={() => {}} onPick={() => {}} {...props} />));
}
beforeEach(() => {
useMail.setState({ mailboxes: MAILBOXES, mailboxesLoaded: true });
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
it("lists folders in the order they were dragged into, not AZ", () => {
open();
expect(rows()).toEqual(["Inbox", "Zeta", "Sent", "Work", "Work / Clients", "Deleted Items", "Alpha"]);
});
it("keeps that order for the folders left after excluding one", () => {
open({ exclude: ["work"] });
expect(rows()).toEqual(["Inbox", "Zeta", "Sent", "Work / Clients", "Deleted Items", "Alpha"]);
});
});
@@ -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"]);
});
});