Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a032ceed7 | ||
|
|
d7be002c19 | ||
|
|
67aab8015a | ||
|
|
6484645a04 | ||
|
|
795fe43cec | ||
|
|
31ab2284ed | ||
|
|
8acb1b66ad | ||
|
|
e9ff2a1e9c | ||
|
|
ea03406646 | ||
|
|
5b353d1e54 | ||
|
|
bb133b88e1 | ||
|
|
5fc63068b0 | ||
|
|
874d25a40c | ||
|
|
c927c69fe2 | ||
|
|
0e63c5d9c9 | ||
|
|
b6f73624b1 | ||
|
|
2c6df11e4b | ||
|
|
171b399e01 | ||
|
|
540554c111 | ||
|
|
7453484280 | ||
|
|
2441e47390 | ||
|
|
6e23c14132 | ||
|
|
6bfd105ad2 | ||
|
|
f627bfc123 | ||
|
|
01dc322aeb |
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 A–Z", () => {
|
||||
// #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"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 A–Z 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", () => {
|
||||
|
||||
@@ -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,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 A–Z 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;
|
||||
|
||||
@@ -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 A–Z 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 A–Z", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user