develop #1

Merged
SebastienDenooz merged 72 commits from develop into main 2026-09-26 13:40:16 +00:00
No description provided.
Create the Nuxt 4.5 / Nuxt UI 4.11 skeleton in SSR mode with the node-server
preset, the en/fr/nl locale files, ESLint with stylistic rules, nuxt typecheck
covering the test directories, and the unit, nuxt and integration Vitest
projects. Add the parseSize/formatBytes helper, the MIT license, editor
settings and the README development section.

Refs: tasks/01-nuxt-scaffold-and-quality-gates.md
Run the end-to-end suite against the production build on port 3100 with a
per-run data directory, Chromium/Firefox/WebKit UI projects and a browserless
API project. Add the GitLab CI, GitHub Actions and Forgejo Actions test
pipelines (lint, typecheck, unit, integration, build, e2e per browser) and a
unit test that checks every pipeline runs all suites on the three browsers.
Forgejo uses its patched artifact actions, since the upstream ones refuse to
run outside github.com.

Refs: tasks/02-e2e-harness-and-ci-pipelines.md
Validate every variable of the configuration reference with Zod, including
the _FILE variants of secrets, and report all invalid variables in one
error without ever echoing a secret. Create the data directory layout and
the generated secret key (mode 0600, written atomically), and provide the
pino JSON logger with secret redaction. A guard test keeps server/core free
of framework imports.

Refs: tasks/03-runtime-configuration-and-logging.md
Open SQLite through node:sqlite with WAL, foreign keys and a busy timeout,
run transactions with savepoints when nested, and apply numbered SQL
migrations under an atomic file lock in a single transaction, with foreign
keys checked before commit. Take one pre-upgrade snapshot per version
change, written under a temporary name and pruned to the configured count.
Add the settings table and a typed settings service with the defaults of
the specification.

Refs: tasks/04-sqlite-layer-migrations-and-snapshots.md
Add the core entities with their constraints, the capability matrix of
the specification, the error classes and the typed event bus. The services
enforce the last active administrator rule, empty-project deletion,
case-insensitive unique names, the instance defaults and size ceiling of
new environments, and effective roles combining project and environment
memberships. createCore wires them on a migrated database.

Refs: tasks/05-core-entities-and-permissions.md
Add the audit log table, the action catalogue and the audit service with
filtered, paginated queries, the Manager scope by environment and the
retention purge. Details that look like secrets are refused. Every mutating
method of the domain services now takes the acting principal and records
its entry in the same transaction as the change.

Refs: tasks/06-audit-log-core.md
Generate unique tb_ usernames and base64url secrets shown once and stored
as SHA-256 hashes. List, relabel, expire, revoke and rotate credentials
with audit entries, and authenticate SMTP logins in constant time with the
invalid, revoked and expired outcomes, writing the last use at most once
per minute.

Refs: tasks/07-smtp-credentials-core.md
Run the catch-all smtp-server with mandatory PLAIN or LOGIN authentication,
routing each session to the environment of its credential and never
relaying. Each message is written gzip-compressed through a temporary file,
fsync and rename before its row and 250 reply. Storage failures, client
disconnects and oversized messages are handled without leaks or hung
sessions, credentials are re-checked for every message, and shutdown waits
only for messages in progress. Sessions, messages and authentication
failures are logged without secrets or content.

Refs: tasks/08-smtp-server-intake-and-raw-storage.md
Configure the none, starttls and implicit modes from the configuration,
with the operator's certificate and key only: invalid, mismatched, expired
or TLS-refused files fail start-up and never replace a working certificate
on reload. Certificates are reloaded when their files change and a warning
event is emitted during the last 14 days. PROXY headers are honoured only
from trusted addresses or CIDR ranges, connections that never finish their
handshake are closed, and core.transportSecurity reports the mode and the
certificate expiry.

Refs: tasks/09-smtp-transport-security.md
Start everything in one process from a Nitro plugin: configuration, data
directory, secret key, the SQL migrations embedded in the build, the SMTP
server and, on first start, the administrator account with a generated
password printed once. Start-up failures (configuration, migrations, HTTP or
SMTP bind) exit with code 1 without leaving a listener behind, and SIGTERM
lets SMTP messages in progress finish. /healthz and /readyz report
liveness and readiness without caching, and dist/server.mjs launches the
production build. Integration tests now run after the build.

Refs: tasks/10-application-bootstrap-and-health.md
Add the sessions table and a sliding 12-hour SessionsService, scrypt
password hashing with the 12-character policy, a per-address and per-IP
sign-in throttle that counts attempts before verification, and an
AuthService recording audited sign-ins and password changes that revoke
the other sessions. A sealed trapbox_session cookie is resolved by a
server middleware that also gates every other API call while a password
change is required. Expose login, logout, me and password endpoints with
the API error envelope, and the login and password-change pages in
English, French and Dutch. Playwright gains a setup project that performs
the administrator's forced password change and stores the signed-in
state.

Refs: tasks/11-password-authentication-and-sessions.md
Configure nuxt-security with a nonce-based content security policy
(no unsafe-inline or eval for scripts), X-Frame-Options DENY, nosniff,
a strict referrer policy, a restrictive permissions policy and COOP,
with its rate limiter, request-size limiter, XSS validator and CORS
handler disabled. A Nitro plugin adds HSTS when the public URL is https
and allows framing the configured content origin once the configuration
is loaded; health probes keep only nosniff and the content frame routes
opt out of the global policy.

Cookie-authenticated mutating requests must pass a Fetch Metadata /
Origin same-origin check (403 csrf_failed), and cross-site sign-ins are
refused. The client IP address honours TRAPBOX_TRUSTED_PROXIES for
sign-in throttling and audit entries. API responses and signed-in pages
are sent with no-store and Vary: Cookie.

Icons render as inline SVG and Zod runs without JIT so that pages load
without CSP violations.

Refs: tasks/12-web-security-hardening.md
Parse every stored message with PostalMime in a pool of worker threads
(bundled to dist/workers by scripts/bundle.mjs, predev and the Vitest
global setup), so large messages never block the main thread. A FIFO
queue with bounded concurrency feeds the pool; a crashed worker is
replaced and its message retried once, and a parse is bounded in time.

Processing records headers, addresses, subject, excerpt, bodies, flags
and counts, derives Bcc recipients from the envelope, stores attachment
and inline part metadata in message_parts and maintains a contentless
FTS5 index of subjects, addresses and text bodies. Malformed messages
and messages whose raw file is missing stay listed as failed with a
processing note. message.received is emitted with the processing
duration, and messages left pending by a crash or a restart are queued
again at start-up.

Refs: tasks/13-message-processing-pipeline.md
When an environment does not store attachments, or a part exceeds its
maximum attachment size, processing now empties the selected parts in a
stripped copy of the raw message: each part keeps its headers and gains
X-Trapbox-Removed with the reason and original size, the message gains
X-Trapbox-Altered after its Date header, and every other byte is kept.
Inline parts within the limit always stay so HTML keeps rendering its
embedded images; an attached message is removed as a single part.

The stripped copy replaces the raw file atomically, the removed parts are
flagged with their reason, the attachments_removed processing note records
the counts, and the environment usage (now counted by the intake) is
adjusted to the stripped size in the same transaction. When the parts
cannot be located unambiguously, they are kept and the message says so.

Refs: tasks/14-attachment-rules.md
Reserve room for every message in a single BEGIN IMMEDIATE transaction
that applies the environment's size limit and its "when full" mode:
rotate out the oldest unpinned, processed messages, or reject
temporarily or permanently. Parallel deliveries can no longer push an
environment past its limits. The environment.quota_reached event fires
once per full episode.

Messages are staged in a temporary file while they arrive and only moved
into place after the size check, the disk guard and the reservation; a
failed store releases the reservation. SMTP replies follow the
specification exactly, with their own enhanced status codes (552 5.3.4,
452 4.3.1, 552 5.2.2).

The disk guard reads free space at most every 10 seconds, rejects new
messages below TRAPBOX_DISK_MIN_FREE and reports readiness as degraded.
Every refusal after authentication is recorded in the new rejections
table (kept 7 days, 500 per environment) and emits message.rejected.
core.messages.delete(ids) removes rows, parts, index entries and files
and releases usage.

Refs: tasks/15-quota-enforcement-and-disk-guard.md
Add an in-process job scheduler (no self-overlap, failures logged per job,
intervals overridable, runNow for tests), started by the bootstrap after
SMTP and stopped first on shutdown. Jobs:

- retention: every 5 minutes, deletes unpinned messages older than the
  environment's retention in batches of 500 (keep-until-deleted
  environments are skipped);
- expiry: every 5 minutes, deletes expired environments with their
  messages, raw directory, parts, index entries, memberships, credentials
  and rejections (audited as environment.deleted), and emits
  environment.expiring at most once a day during the last 7 days;
- audit-retention (daily), rejections-prune and sessions-cleanup
  (hourly), snapshot-cleanup (at start) and orphan-files (hourly: raw
  files without rows are deleted, rows without files are annotated
  raw_missing).

Also fix full-text index writes leaving the connection's last insert
rowid above 2^53, which made node:sqlite throw on later statements.

Refs: tasks/16-job-scheduler-retention-and-expiry.md
Each credential is limited to its environment's rate_limit_per_minute
over a 60-second sliding window, checked at MAIL FROM so nothing is
uploaded in vain: the message over the limit gets 451 4.7.1, is listed
in the recent rejections with reason rate and emits message.rejected.

Authentication failures are counted per client address (the PROXY
protocol address when configured): the 10th failure within 15 minutes
blocks the address for 15 minutes, a success resets the count, and
blocked addresses are refused with 421 4.7.0 at connection time and on
further AUTH attempts. Failures, blocks and rate refusals produce
smtp.auth_failed, smtp.ip_blocked and smtp.rate_limited log lines that
never contain secrets. Both limiters are exposed on core.smtp.

Refs: tasks/17-smtp-rate-limit-and-brute-force-protection.md
Every /api error now uses the { error: { code, message, details } }
envelope: route handlers, a catch-all answering 404 not_found or 405
method_not_allowed (with Allow) for addresses no route serves, and a
Nitro error handler placed before Nuxt's. Zod validation errors become
400 validation_failed with [{ path, message }] details. Lists use
?page/?per_page (1-200) and answer { items, page, per_page, total }.

Authorization goes through guards: requireUser, requireAdmin,
requireScope and requireEnvironmentCapability, which answers 404 for
environments the caller cannot see and 403 only when a visible one lacks
the capability.

Nitro generates the OpenAPI document at /api/v1/openapi.json (limited to
/api/v1 endpoints) with the Scalar reference at /api/v1/docs; every route
declares its metadata. First endpoints: GET /api/v1/environments,
/api/v1/environments/{id} (usage, settings, banner flags) and
/api/v1/me. The app gains useApi() with typed ApiError, shared DTO types
and an API conventions document; end-to-end tests seed data through the
core services.

Refs: tasks/18-api-foundation-and-openapi.md
Add the dashboard shell: a collapsible sidebar with the instance name,
the environment selector (grouped by project, standalone last, filtered
by environment or project name, client-side navigation, current
environment highlighted) and account links, a command palette opened
with Ctrl/Cmd K, and a per-page navbar with the title, the color-mode
switch and the user menu (language, sign-out), plus a skip link.

The interface language comes from the user's profile (changed through
PATCH /api/v1/me), else the browser language, else the instance default,
and is chosen identically on the server and in the browser. Dates are
formatted in the user's locale and time zone after hydration. Page
titles, favicon and accent color come from the new public
GET /api/v1/instance. The home page opens the last visited environment;
environment pages show their header, and hidden environments render a
localized 404 page. Administrators opening an environment they are not
a member of are audited once per session.

Also fix a request-context loss in the route middleware that failed
concurrent renders, hydration id mismatches, and a drawer stylesheet
blocked by the content security policy.

Refs: tasks/19-application-shell-and-environment-selector.md
Record a personal read status per user (and, later, per environment
token) in message_reads. GET /api/v1/environments/{id}/messages lists
the messages newest first with the caller's read status joined in the
same query; opening a message (summary endpoint, placeholder page) marks
it read.

GET /api/v1/events streams, per browser session, the message and
environment events of every environment the user can read. Access and
session validity are re-checked for every event; a ready event follows
the subscription, a heartbeat keeps the connection alive, and open
streams are closed on SIGTERM so that shutdown does not wait for them.

The webmail keeps one EventSource per tab with exponential reconnection
and a discreet "Reconnecting..." indicator, and the message list (unread
in bold, attachment, pin and parsing indicators, relative dates,
keyboard navigation, load more) applies new, updated and deleted
messages live: a new message shows within 2 seconds of its acceptance.

Icons are now bundled locally, so the browser never contacts the
Iconify API, which the content security policy refuses.

Refs: tasks/20-message-list-and-real-time-updates.md
Show a message's HTML in a sandboxed, script-free frame served on the
content origin through short-lived signed links, with its own content
security policy and remote content blocked until the user loads it.
The email HTML is transformed as a parsed tree (no scripts, frames,
objects, forms, event handlers, dangerous URLs, meta refresh or base;
cid: images rewritten to signed part links; links in a new tab without
referrer). Inline parts are extracted on demand by the parse worker and
kept in a small LRU cache.

Add GET /api/v1/messages/{id} (marks the message read) with its text,
highlighted HTML source (shiki), frame URL and raw text endpoints, and
the message page with its HTML, Text, HTML source, Raw message, Headers
and Envelope and session tabs, the altered-copy and malformed banners
and the 404 state. The placeholder summary endpoint is replaced.

Cover the transform, signatures and part cache with unit tests, the
endpoints, content routes and page with end-to-end tests, and 32 known
XSS payloads sent over SMTP and opened in the browser.

Refs: tasks/21-message-view-and-isolated-rendering.md
Serve attachments with their declared content type, no sniffing, a
sandboxing policy and an RFC 6266/5987 filename sanitized from the email
headers (404 attachment_removed for removed contents), image previews
for thumbnails (415 preview_unavailable otherwise), and the stored raw
message as a streamed .eml download labeled X-Trapbox-Altered when it is
the stripped copy. Part responses share the worker extraction and cache
of the content routes.

Complete the message view with the attachments panel (thumbnails,
sizes, downloads, removed badges), the removed-attachments notice, the
responsive preview presets and custom width, and previous/next buttons
with j/k shortcuts following the list order through a new neighbours
endpoint, the list query kept in the route.

Also replace the fixed wait of the XSS suite by a condition.

Refs: tasks/22-attachments-downloads-preview-and-navigation.md
Let every role mark messages read or unread for themselves, with the
change announced to that user's sessions only, and let Members and
Managers delete a message, act on up to 500 selected messages at once
(ids of other environments are ignored and reported, deletions are
atomic) and delete all messages of an environment. Delete all works in
batches of 500, keeps pinned messages unless asked, and is recorded in
the audit log with its counts, actor and address.

The message list gains row selection, select-all on the page, a bulk
action bar and a confirmed delete-all dialog with the "also delete
pinned messages" option; the message view gains a confirmed delete and
"mark unread", and returns to the list when its message is deleted
elsewhere. Controls the role does not allow are hidden; the server
enforces the rules.

Refs: tasks/23-message-actions-read-delete-bulk.md
Store message tags (case-insensitive, first spelling kept) with the
limits of 10 tags per message and 50 characters per tag enforced by the
core. Processing tags messages from every X-Tags header and, when the
environment enables it, from plus addressing of the envelope recipients,
keeping 10 and noting the dropped and refused tags, before the message
is announced.

Add the tags endpoint of a message, the bulk tag and untag actions
(messages at the limit are reported), the environment's tag list with
counts, the ?tag= filter of the list (also applied to previous/next and
live updates), and the Manager-only plus-addressing setting, recorded in
the audit log.

The web app shows tag chips, a tag editor in the message view, bulk tag
dialogs, a tag filter with its chip, and the tagging settings page.
Every locale message is now compiled by a unit test: a literal "@" had
broken the page's server rendering.

Refs: tasks/24-tags-and-automatic-tagging.md
Add the pins service: pinning and unpinning are idempotent, announce the
change, and refuse with 409 pinned_limit_reached once the environment
holds the instance's pinned_limit, the count and the update sharing one
immediate transaction. Pinned messages stay in the quota; rotation,
retention and delete all keep them, as proven by new integration tests
over SMTP and the jobs.

Expose PUT and DELETE /api/v1/messages/{id}/pin, the ?pinned= list
filter (also used by previous/next and live updates) and pinned_limit in
the environment usage. The web app gains pin toggles in the list rows
and the message view, a "Pinned" filter and a toast at the limit;
Viewers see the indicator only.

Refs: tasks/25-pinned-messages.md
Compile the list filters in the core from fixed SQL fragments with bound
values: full-text search through a safe FTS5 expression (every term
quoted, phrases kept, trailing * as prefix, operators searched as text),
sender and recipient substrings over the decoded addresses, reception
date range, tag, attachments, personal unread status and pinned. The
page, its total and previous/next share the same conditions, and the
environment and date predicates stay index ranges, as a query-plan test
on 10,000 messages shows.

The list endpoint validates the new parameters (400 validation_failed
for a malformed date or boolean). The environment page gains a search
box applied 300 ms after typing, a filters popover, removable filter
chips, a result count and a "No message matches" state, all kept in the
route query so that reload and back navigation restore them; live
messages outside the filters are not shown.

Refs: tasks/26-search-and-filters.md
Add GET /api/v1/environments/{id}/usage (storage and message count
against their limits, pinned messages against the instance limit,
retention, expiry, quota reached, expiry within 7 days and the when-full
mode) and GET /api/v1/environments/{id}/rejections (the messages refused
after authentication during the last 7 days, at most 500, newest first),
visible to every role of the environment.

The environment view gains a usage gauge that turns to warning above
80 % and to error at 100 %, banners for a full quota (explaining what
happens to new messages) and an approaching expiry, with a slot for the
failure-injection banner, and a rejections page with reason badges and
the injected marker. Usage and banners follow the live events.

Also let the e2e seeding helper wait while another test worker holds
the migration lock, and make the previous/next e2e test wait for each
message's own frame before the next key press.

Refs: tasks/27-usage-gauge-rejections-and-banners.md
Give Members and Managers the SMTP settings of their environment (host,
port, TLS mode, authentication mechanisms, clear-text flag) and its
active credentials, never a secret, with ready-to-copy Laravel, Symfony
and Nodemailer snippets and a generic settings table. The secret shows
as a distinct <secret> placeholder, except right after a credential is
created: then it is kept in memory only, for that credential, until the
panel is left or for 10 minutes.

"Send test email" builds a sample message (HTML and text versions, an
inline image and a small attachment) with Nodemailer's MailComposer and
stores it through the intake, as SMTP messages are: quota, disk and size
checks, storage and processing apply, and nothing is sent anywhere. A
full environment answers 409 environment_full.

Also reject HTML-looking locale messages in the locale test: the i18n
build plugin refuses a locale holding one.

Refs: tasks/28-connection-panel-and-test-email.md
Add the credentials endpoints of an environment for its Managers:
creation with a generated username and a secret returned in that
response only, the list with each credential's status (active, expired
or revoked), changes of the label and the expiry date (a date in the
past expires the credential at once; a revoked credential answers 409
credential_revoked) and immediate revocation. Every change is recorded
in the audit log with the actor and the address, never the secret.

The settings page lists the credentials and lets Managers create one,
showing its secret once in a dialog that cannot be dismissed by
accident, with a copy button and a hand-off to the connection panel,
which keeps it in memory for that credential only; it also edits labels
and expiry dates and revokes after confirmation.

Refs: tasks/29-credential-management.md
Let users rename themselves and choose their language, applied at once
and kept for every later page, through PATCH /api/v1/me, and change
their password through POST /api/v1/me/password, for browser sessions
only: the current password is checked (400 wrong_password), the policy
of at least 12 characters applies (400 validation_failed on
new_password), accounts of the identity provider are refused (409
password_not_available), and the other sessions of the user are signed
out while the current one stays. The profile also gives its creation
and last sign-in dates.

The settings page has a Profile tab (name, language) and a Security tab
(password form with inline errors, or a notice for single sign-on
accounts), with the Tokens and Feeds tabs to come, and is linked from
the sidebar and the user menu.

Refs: tasks/30-personal-settings-profile-password-language.md
Users create personal tokens (tbp_ followed by 43 characters) in the
personal settings or through POST /api/v1/me/tokens: the token appears
once, Trapbox keeps its SHA-256 hash and a 12-character prefix for
display, and lists show the scopes, expiry, last use and status. Tokens
can expire and be revoked at once; creation and revocation are audited
with the actor and address, never the token.

The auth middleware resolves Authorization: Bearer tbp_… on the API into
a personal-token principal acting for its owner. Unknown, expired or
revoked tokens and tokens of disabled users answer 401 invalid_token,
and each token is limited to 600 requests per minute by an in-memory
sliding window (429 rate_limited with Retry-After). Bearer requests are
exempt from the same-origin check.

Every /api/v1 route now checks the owner's role first, then the scope of
its family: read for reads, write for message actions, manage for
credentials and environment settings, provision for environment
creation and deletion. Token management, the password, the profile
update, live events and the sign-in routes need a browser session
(403 session_required). A unit test checks the scope of every route.

The personal settings become nested pages with link tabs (profile,
security, API tokens, feeds); the tokens page offers scope checkboxes,
an optional expiry, the one-time token with a copy button and revocation
with confirmation, in English, French and Dutch.

Refs: tasks/31-personal-api-tokens.md
Managers and administrators create environment tokens (tbe_ followed by
43 characters) with read or read and write access and an optional
expiry, for CI pipelines and Mailpit-compatible clients. The token is
shown once and stored as a SHA-256 hash with a 12-character prefix; the
list shows access, last use and status, and revocation is immediate.
Creation and revocation are audited with the environment, the actor and
the address. Deleting an environment deletes its tokens.

Authorization: Bearer tbe_… resolves to an environment-token principal
that shares the per-token rate limit of personal tokens. It reaches its
own environment only: any other environment and its messages answer
404. Read access lists, reads and marks messages and sees rejections;
read and write access also deletes, tags and pins. Anything else,
including credentials, the connection panel and token management,
answers 403 insufficient_scope. Each token has its own read status,
distinct from every user's.

The environment page links Managers to the new tokens page, with the
access choice, the one-time token and its copy button, and revocation
with confirmation, in English, French and Dutch.

Refs: tasks/32-environment-tokens.md
Managers and administrators change the expiry date of an environment
with PATCH /api/v1/environments/{id}, which accepts only expires_at: a
date strictly in the future by the core clock (400 validation_failed
with the detail code expires_at_in_past) or null to clear it. Any other
field is refused, so quotas and retention stay with the administrators.
The response is the environment details with the new expiring_soon
flag, and each change is audited as environment.expiry_set with the
previous and new dates. The expiry job deletes the environment at a
Manager-set date like any other.

GET /api/v1/environments/{id}/settings gives Managers every setting of
the environment with expiring_soon; other roles get 403.

The environment's Settings entry now opens an overview for Managers:
quotas and retention, read-only with a note that only administrators
change them ("Keep until deleted" flagged), the expiry date with set,
extend and a confirmed clear, and links to tagging, credentials and
tokens, in English, French and Dutch.

Refs: tasks/33-manager-environment-settings-expiry.md
GET /api/v1/environments/{id}/messages/wait answers with the first
message matching every given criterion: a recipient (envelope, To, Cc
or Bcc address), a sender (envelope or From address), a subject
substring and a tag, all without case, received after a date that
defaults to the request start. A message already processed and matching
is returned at once, the newest first; otherwise the wait subscribes to
message.received and answers as soon as a matching message is
processed, within a second of its SMTP acceptance, or answers
{ status: "timeout", message: null } after 1 to 120 seconds (30 by
default). Waiting never marks the message read.

The wait is event-driven: one listener and one timer, both removed on a
match, a timeout or a client disconnect. Every role may wait, with the
read scope; environment tokens wait on their own environment only.

docs/api/wait-for-message.md documents the parameters, both answers and
a Playwright example that replaces a polling loop.

Refs: tasks/34-wait-for-message.md
A personal token with the provision scope, owned by a Manager of the
project or an administrator, creates an environment in the project with
POST /api/v1/projects/{id}/environments. It gets the instance defaults,
an SMTP credential labelled provisioned and a read_write environment
token named provisioned. All three are created in one transaction, and
the secret, the token and the SMTP connection settings are returned
once. Members and Viewers of the project get 403, projects the owner
cannot see 404, and a name already used in the project 409.

POST /api/v1/environments/{id}/credentials/rotate creates a new
credential and lets the other active ones expire after grace_minutes
(60 by default, 0 at once, at most 1440), listing them with their new
expiry. DELETE /api/v1/environments/{id} deletes an environment and all
its data for the project's Managers and the administrators; standalone
environments are for administrators only. GET /api/v1/projects lists the
caller's projects with their role.

Every step is audited with the token's owner as actor, never with a
secret. docs/api/provisioning.md walks through the merge-request flow.

Refs: tasks/35-provisioning-api.md
Link checks and webhooks will contact addresses chosen by others, so
Trapbox gets one outbound client, core.outbound.fetch. It refuses any
destination whose host resolves to a private, loopback, link-local,
cloud-metadata, reserved or multicast address, including IPv4-mapped
and NAT64 forms. A single private answer among public ones is enough to
refuse, so DNS rebinding cannot slip through.

The connection is pinned to the vetted addresses through an undici
Agent whose lookup never asks DNS again. Redirects are followed by hand
and each hop is checked with the same rules, at most five. Only http and
https are allowed, credentials in URLs are refused, and credential
headers never follow a redirect to another origin. Timeouts, a response
size limit and a global concurrency limit of ten apply. Refusals are
logged at warn with the reason and the host, never the response.

The administrators' allowlist (host names, IP addresses and CIDR ranges)
is read from the instance settings at every call and can never allow a
cloud-metadata address. ESLint now rejects the global fetch anywhere in
the core outside the outbound module. undici becomes a direct
dependency.

Refs: tasks/36-outbound-request-policy.md
The message view gets an Analysis tab with a link check. The check
covers every link and linked image of the HTML and text versions: a,
area and img in the HTML, http(s) URLs in the text, each once, at most
200. Each one is reported as working, redirected, broken, timed out or
blocked by policy, with its HTTP status, final URL and redirects.

Links are requested only through the outbound client, so private and
internal addresses are never contacted unless allowlisted. Each link
gets 10 seconds, five are checked at a time, and GET is tried when HEAD
is refused.

Analyses get their own framework: results are stored for one hour,
refreshed on demand, deduplicated while running, and purged by a job
every 15 minutes. Background analyzers answer 202 at once.
POST /api/v1/messages/{id}/analysis/links starts a check (force=true
refreshes it); GET returns the result, 202 while running, or 404
not_computed. When administrators turn the link checker off, both
answer 404 feature_disabled and /api/v1/instance reports
features.link_check false. Every role may run it, with the read scope.

Refs: tasks/37-link-check.md
The Analysis tab of the message view gets an HTML compatibility check.
It lists the HTML and CSS features the message uses and their support in
the main email clients: Gmail, Outlook, Apple Mail, Yahoo and 44 others,
grouped as desktop, webmail and mobile. It gives an overall score,
per-group scores, and a warning for each feature a major client does not
or only partly support. The report always names its source, the "Can I
email" dataset, with the date of the snapshot.

The dataset is bundled: npm run update-caniemail downloads and validates
it and writes the snapshot, keeping each client's versions in
chronological order so that the latest one is used. The snapshot of
2026-09-16 is committed, and THIRD_PARTY_NOTICES.md carries its MIT
notice.

Detection walks the HTML with htmlparser2 and the style sheets and style
attributes with css-tree, skipping invalid CSS, and maps what it finds
to dataset features through an explicit table checked against the
snapshot. The check runs within the request (POST, force=true to
recompute) and is kept for an hour like the other analyses (GET).

Refs: tasks/38-html-compatibility-check.md
When the operator sets TRAPBOX_SPAMASSASSIN, the Analysis tab of the
message view gets a spam score. The stored message is submitted to the
SpamAssassin daemon with the SPAMC/1.5 REPORT command, and the section
shows the verdict, the score against the threshold and every rule
triggered with its points and explanation. Messages above 512 KiB are
not submitted. A copy whose attachments were removed is flagged, since
its score may differ from the original.

The client speaks SPAMC over TCP with a 30-second timeout. It reads the
report tolerantly (wrapped descriptions, negative points, \n line
endings) and answers 503 spam_service_unavailable when the daemon cannot
be reached, is too slow or fails. The message and the report never
appear in logs or error responses.

Without TRAPBOX_SPAMASSASSIN, /api/v1/instance reports
features.spam_score false, the section is absent, and
GET/POST /api/v1/messages/{id}/analysis/spam answer 404
feature_disabled. The end-to-end server runs against a fake spamd
started by the Playwright global setup.

Refs: tasks/39-spam-score.md
Each user can create an RSS feed for each environment they can see,
from the Feeds tab of the personal settings or with
POST /api/v1/me/feeds. The address, <public URL>/feeds/<tbf_ token>.xml,
is shown once: Trapbox keeps a SHA-256 hash and a prefix. It can be
regenerated (the old address answers 404 at once) or revoked, always
after a confirmation in the interface.

The feed is RSS 2.0 built with the feed package. It holds the 50 newest
processed messages with subject, sender, recipients, tags, date and a
link to the message, which requires signing in. Text excerpts appear
only when the environment allows them, and bodies and attachments
never. Everything senders control is escaped, and responses are never
cached.

A feed stops as soon as its user loses access: on the membership change
or the user's deactivation, and in any case at the next request, which
checks the access again and revokes the feed on the spot. Deleting the
environment deletes its feeds. Feed tokens are audited like the other
tokens. Managing feeds needs a browser session.

Refs: tasks/40-rss-feeds.md
Add the webhook delivery engine of spec §10.6: webhooks, deliveries and
delivery log tables; whsec_ secrets stored under AES-256-GCM with an
HKDF-derived key; Standard Webhooks signatures; summary-only payloads in
the versioned generic JSON format and in Slack, Discord and Microsoft
Teams formats; tag and recipient filters; fan-out on message.received,
message.rejected and environment.quota_reached that only inserts rows;
the webhooks.deliver job (every 5 s, 5 at a time, through the outbound
policy, no redirects) with retries for 24 hours, a 100-row log and
automatic disabling after 24 hours of failure (webhook.disabled).

Refs: tasks/41-webhook-delivery-engine.md
Add the webhook management API of spec §10.6 (list, create with the
signing secret shown once, change, delete, rotate the secret, send a
test event, enable again, delivery log), guarded by the Manager
capability and the manage scope, validated with Zod and checked
against the outbound policy before saving (400 url_blocked); every
change is audited, webhook.enabled included. Add the Webhooks settings
page with the disabled-webhook alert, the create and edit slideover,
the secret-once modal, row actions and the delivery log drawer, in
English, French and Dutch.

The delivery engine now re-reads a webhook when an attempt ends, so
that concurrent failures after the disabling leave nothing pending and
a webhook deleted during a request does not break the log. End-to-end
tests get a webhook receiver and an allowlisted 127.0.0.1.

Refs: tasks/42-webhook-management.md
Add failure injection (spec §5.7): one rule per environment, set by its
Managers from the settings or the API (GET/PUT/DELETE, manage scope,
audited), that makes the SMTP server answer a common error from the A34
catalogue at the authentication, sender, recipient or message-content
step, for every message, a percentage of them or the next N, after an
optional non-blocking delay of up to 60 seconds. Rules expire on their
own (1 hour by default, 24 hours at most) and a job removes them.

Injected failures are recorded among the recent rejections, marked as
injected, and emit message.rejected; nothing is stored. The environment
details, usage and connection responses carry a failure_injection flag
shown as a banner in the environment view and the connection panel.

The disabled-webhook end-to-end seed now abandons a single delivery, so
the last delivery status no longer depends on which attempt ends last.

Refs: tasks/43-failure-injection.md
Add the instance settings of spec §9.1: GET and PATCH
/api/v1/admin/settings for administrators, validating every field with
shared Zod schemas (name, accent color name, default language, defaults
of new environments within the instance maximum message size, pinned
limit, link checker, outbound allowlist with the invalid entries
listed, audit retention) and auditing the changed keys.

Logo and favicon uploads (§11.5) accept PNG, JPEG and WebP recognised by
their content and SVG files sanitized of scripts and external
references, up to 1 MiB; they are stored under their hash and served
publicly from /branding/logo and /branding/favicon with their exact
type, nosniff, an ETag, a five-minute cache and a sandbox policy. The
sign-in page, the sidebar, the titles, the favicon and the accent color
follow the settings at once.

The administration area starts with the six-section settings page, in
English, French and Dutch.

Refs: tasks/44-instance-settings-and-whitelabel.md
Add user administration (spec §9.2, §4.5): administrators list accounts
with their status, sign-in method, administrator flag, last sign-in and
memberships, create accounts with a one-time setup link valid 72 hours
(none for accounts that sign in through single sign-on only), change,
disable, enable and delete them, generate a new link for a forgotten
password, and replace their memberships. Disabling signs the user out
and revokes their personal tokens and feeds in the same request;
deleting removes everything of the account. The last active
administrator cannot be demoted or disabled, and nobody deletes their
own account.

The public setup page lets the user choose a password through the
link, which works once, and signs them in. Setup link tokens are stored
as hashes and never appear in lists, logs or the audit log.

Refs: tasks/45-user-administration-and-setup-links.md
Add project administration (spec §9.3): administrators list, create,
rename and delete projects (only empty ones; names unique whatever the
case) and replace their members, each change audited. Members open a
project page listing its environments with their usage; its Managers
and the administrators create environments there through the
provisioning route, with the instance defaults and the first secrets
shown once, and delete them. The sidebar's project headers and, for
users without an environment yet, the home page lead to the project
pages.

Forms that show errors from the API now validate typed input without a
delay: a pending debounced validation could clear an error the server
had just reported.

Refs: tasks/46-project-administration-and-project-environments.md
Add environment administration (spec §9.3, §7): administrators list
environments with their project, usage and expiry, create them in a
project or standalone with the instance defaults and optional
overrides, rename and move them (names unique in their scope), change
their quotas and retention ("Keep until deleted" only with an explicit
acknowledgment, the maximum message size within the instance maximum,
attachments within messages), replace their members, purge all their
messages including pinned ones, and delete them with all their data.
Every change is audited.

The detail page shows the settings form with the instance ceiling, the
environment and inherited project members, links to the pages its
Managers use, and purge and deletion confirmed by typing the name. The
public instance information now includes the maximum message size.

Refs: tasks/47-environment-administration.md
Add the instance overview (spec §9.4) for administrators: the data
volume (total, free, used by Trapbox, the free-space threshold and the
disk guard, reported as /readyz reports it), the numbers of users,
projects, environments and messages, the usage of every environment
largest first, and the alerts computed from the current state: disk
guard (critical), SMTP certificate expiring within 14 days (critical
from 3), webhooks disabled after failing for 24 hours and environments
expiring within 7 days, each linking to where it is dealt with.

The overview page refreshes every minute; every administration page
shows a permanent notice while SMTP credentials travel unencrypted.

Refs: tasks/48-instance-overview-and-alerts.md
Add the consultation of the audit log (spec §9.5): administrators
filter it by actor, action or family of actions, target type,
environment, project, period and text, open each entry's details, and
export the filtered entries as a CSV file (RFC 4180, streamed from a
database cursor, at most 100,000 entries). Managers read the audit
trail of their own environment, which never shows entries of another
one.

Refs: tasks/49-audit-log-administration.md
Sign in through any OpenID Connect provider (spec §4.5): openid-client
discovery cached after the first success and retried with a growing delay
(503 sso_unavailable meanwhile), PKCE S256, state and nonce kept in a
sealed 10-minute trapbox_oidc cookie, and a UserInfo fallback for the
email claims.

- /auth/oidc/start and /auth/oidc/callback, with /login?error=... redirects
- accounts matched on the verified email address, case-insensitively;
  disabled accounts refused; pending accounts activated
- optional automatic account creation without memberships
- TRAPBOX_LOCAL_LOGIN=admins refuses other accounts' passwords with 403
  local_login_disabled after the password check, still throttled
- GET /api/v1/instance exposes sso { enabled, label, local_login }
- sign-in page: SSO button, administrator sign-in link, localized errors
- sign-in audit entries carry the method and the failure reason
- mock OpenID Connect issuer, integration and e2e tests

Refs: tasks/50-oidc-single-sign-on.md
With TRAPBOX_SETUP_MODE=web on an empty instance (spec §12.2), the
bootstrap opens the web setup instead of creating an administrator: a
generated setup token is printed once in a visible block (a configured
TRAPBOX_SETUP_TOKEN never is), the SMTP server stays closed and /readyz
reports setup: false while /healthz answers as usual.

- gate middleware: pages redirect to /setup, the API answers 503
  setup_required; health, the setup routes and the page's assets pass
- setup API: status, verify-token, logo and completion; tokens compared
  in constant time and throttled per client address (403
  invalid_setup_token, 429 rate_limited with Retry-After)
- completion creates the administrator, names the instance, sets its
  default language, records the audit entries, opens SMTP and signs in
- every setup route answers 404 for good once an administrator exists,
  in web and auto modes and across restarts
- four-step wizard page (token, administrator, instance with logo,
  done) in English, French and Dutch
- integration tests on spawned instances and a Playwright spec on its
  own instance

Refs: tasks/51-web-setup-wizard.md
Close spec §10.2: every route under server/api/v1 is documented, errors
share one envelope and lists one pagination shape, and the reference
explains how to authenticate (§10.1).

- data-driven Playwright suite reading the route files from disk: each
  one must appear in /api/v1/openapi.json with a summary, tags, its path
  parameters and responses; probes of the envelope for 400, 401, 403,
  404 and 405; pagination shape and per_page bounds of every list
- GET /api/v1/messages/{id}/headers: the ordered headers alone
- path parameters documented on the ten routes whose query parameters
  hid them
- "Getting started" description (session cookie, tbp_ and tbe_ tokens,
  scopes, 600 requests per minute, errors, pagination, wait, provisioning,
  Mailpit base URL), the public URL as server, session and bearer
  security schemes; readable without signing in
- docs/api/README.md leads to the reference and the guides
- "API reference" item in the user menu, in a new tab
- fix a timing flake of the content frame expiry test

Refs: tasks/52-rest-api-completeness-and-reference.md
Existing Mailpit clients and test helpers work with Trapbox given a new
base URL, <public URL>/mailpit, and an environment token as HTTP Basic
credentials or a bearer token (spec §10.5).

- authentication middleware: environment token (Basic or Bearer), 401
  with a Basic challenge, 403 for changes with a read token, per-token
  rate limit
- info, webui, message list, search and delete by search, a message
  (with latest), its headers, raw source, parts and image thumbnails,
  read status, deletion (all messages include pinned ones) and tags,
  in Mailpit's shapes with plain-text errors; unsupported endpoints 404
- MailpitService scopes every read and change to the token's
  environment, with the token's own read status
- search syntax: free text, quoted phrases, from:, to:, subject:, tag:,
  is:read, is:unread, has:attachment, before: and after:; the query
  builder gains a subject filter and repeated values
- Reply-To parsed by the worker and stored with the message
  (migration 0019)
- swagger.json snapshot of Mailpit v1.31.2 and a reference-client suite
  (mailpit-api) checking every response property by property

Refs: tasks/53-mailpit-compatible-api.md
Both are enabled through environment variables only (spec §14.4, §14.6).

- per-core prom-client registry: messages accepted and rejected by
  reason, SMTP connections and open sessions, authentication failures,
  processing time histogram, storage and messages per environment,
  webhook attempts by outcome and disabled webhooks, live connections,
  free disk space and disk guard, setup state, version
- core modules feed it through events (smtp.connected, smtp.auth_failed,
  webhook.attempted) and scrape-time gauges; labels are environment ids,
  reasons and statuses only
- /metrics behind TRAPBOX_METRICS_TOKEN as a bearer token (404 without
  it, 401 with WWW-Authenticate otherwise), outside the setup redirect
- TRAPBOX_SENTRY_DSN enables @sentry/node 11, loaded only then: no
  tracing, no personal data, a scrubber removing bodies, cookies,
  authorization, secrets and message content; reports from API 500s,
  jobs, the SMTP server, parser crashes, startup failures, unhandled
  rejections and uncaught exceptions (which end the process), flushed at
  shutdown
- the e2e server scrapes with its own token

Refs: tasks/54-metrics-and-error-reporting.md
The command-line tool of spec §14.8, bundled by the build as the
executable dist/trapbox.mjs (package bin "trapbox"), with the version
and the migration list of the build injected at bundle time.

- version prints the package version
- check validates the configuration, the data directory, the free
  space against the disk guard threshold, the TLS certificate of the
  TLS modes (readable, parsable, not expired, key match; a warning
  within 14 days), the ports and, unless --skip-network, the single
  sign-on provider's discovery and SpamAssassin; --json; exit 1 on any
  failure
- reset-admin-password <email> prints a new 72-hour setup link on
  stdout alone and ends the administrator's sessions (spec §12.3); it
  never migrates and refuses pending migrations, unknown,
  non-administrator and disabled accounts
- checks and recovery are core functions; the CLI loads no Nuxt, Nitro,
  h3, worker or Sentry code (architecture test)

Refs: tasks/55-cli-check-version-and-admin-recovery.md
Consistent online backups and offline restores (spec §14.7, §14.8).

- the server records its process in <data>/trapbox.pid at start and
  removes it at shutdown; a stale marker is ignored
- trapbox backup <file> [--force] [--quiet]: SQLite online backup from a
  read-only connection, manifest.json, messages, uploads and the
  generated secret key in a gzip tar written to <file>.tmp then renamed;
  snapshots, temporary files, WAL side files, the lock and the marker
  stay out; works while Trapbox runs or not
- trapbox restore <file> [--force]: refused while an instance runs,
  over existing data without --force, and for archives of a newer
  version; every entry and the manifest are checked before extraction
  (no absolute paths, no .., no links, known names only); the data is
  extracted under tmp/ then moved into place per directory; warns when
  no secret key is available
- docs/operations/backup-restore.md (procedure, key handling,
  recommendations, snapshots, downgrades) and a README section

Refs: tasks/56-backup-and-restore.md
The image is Trapbox's distribution form (spec §14.1, §14.6, §15, §17).

- docker/Dockerfile: multi-stage on node:24-alpine; the runtime stage
  holds the Nitro output with its traced modules, the bundled launcher,
  workers, CLI and health check, the license notices and no package
  manager, build tool or development dependency; user 1000:0 with a
  group-writable /data (arbitrary uids in group 0), environment
  defaults, VOLUME, EXPOSE 3000 1125, a Node health check on /healthz,
  SIGTERM, OCI labels from build arguments
- docker/trapbox shim, docker/docker-compose.yml (read-only, tmpfs /tmp,
  named volume) and .dockerignore
- scripts/generate-licenses.mjs writes the third-party license inventory
  of the production dependency tree during the image build
- the docker Vitest project (npm run test:docker) builds the image and
  runs it read-only as uid 12345: healthy, SMTP to API, writes limited
  to /data and /tmp, trapbox version, notices, labels, graceful stop
  within 30 s, 66.6 MB compressed (budget 150 MB)
- a docker job in the GitLab, GitHub and Forgejo pipelines

Refs: tasks/57-container-image.md
Automate releases (spec §14.2, §14.3, §17, §12.4).

- scripts/image-tags.mjs: X.Y.Z, X.Y, X and latest for a stable version,
  the exact version only for a pre-release
- GitLab release stage on vX.Y.Z tags, after the test stage: buildx
  build of linux/amd64 and linux/arm64 with SBOM and provenance to the
  GitLab registry, Trivy gate on fixable critical vulnerabilities for
  both platforms, publication to alchemicstudiohq/trapbox and the
  registry mirror with the computed tags, cosign signing (keyless or by
  key), GitLab release from the git-cliff notes
- GitHub and Forgejo release workflows running the test workflow first,
  metadata-action semver tags without latest on pre-releases, a scan of
  a local build before the multi-platform push, cosign signing (keyless
  on GitHub, by key on Forgejo) and the release notes
- cliff.toml (breaking changes first), npm run changelog, CHANGELOG.md
  and docs/releasing.md (checklist and release notes template)
- renovate.json: weekly, grouped minor and patch npm updates, lock file
  maintenance, pinned digests, Conventional Commits

Refs: tasks/58-release-pipeline-and-versioning.md
Guides and manifests for the platforms of spec §14.5, with the rules of
§14.1, §5.2, §13 and §11.6 (single instance, local block storage,
encrypted volume, backups before upgrades, secrets as files).

- docs/deploy: index, Docker Compose, Coolify and Traefik, Kubernetes
  and OpenShift, and any reverse proxy (nginx, Caddy, Traefik)
- deploy/docker-compose: one read-only service, one named volume, both
  ports, tmpfs /tmp, 30 s stop grace period, file-based secret key
- deploy/traefik: smtp TCP entry point and router forwarding raw TCP
  with the PROXY protocol v2 (no TLS termination), HTTP router
  flushing the live updates
- deploy/kubernetes: single-replica Recreate Deployment under the
  restricted Pod Security Standard with probes and an emptyDir /tmp,
  RWO PVC, HTTP Service and Ingress without buffering, SMTP
  LoadBalancer keeping client addresses, kustomization
- test/deploy (npm run test:deploy, run in the CI docker jobs): docker
  compose config, kubeconform strict, Traefik structure and links

Refs: tasks/59-deployment-guides.md
Audit every page of the webmail and the administration with axe in the
light and dark themes (dialogs, environment palette, message tabs, API
reference), and prove the keyboard interactions in a dedicated suite.

- Contrast: darker light-theme shades for the semantic colors, accent
  swatches showing the shades applied, Scalar theme overrides.
- Document language from the locale, focus on the main heading after
  navigation, live announcements of arriving messages and of the live
  updates' status, preview presets as a toolbar, focus ring on list
  rows, reduced motion, visually hidden headers for untitled columns.
- Hydration: keep onServerPrefetch in the browser bundle so that useId()
  matches the server (labels were detached from their fields), skip
  route middleware reloads while hydrating, resolve the palette hint
  after mount.
- Firefox: Enter in a tag input inside a form no longer submits it.
- Tests: recorded clipboard instead of permissions, focus
  synchronizations after selects, platform-aware palette shortcut;
  npm run test:e2e:all runs Chromium, Firefox and WebKit.

Refs: tasks/60-accessibility-and-browser-compatibility.md
A Vitest project `perf` (`npm run test:perf`, after `npm run build`)
measures the non-functional targets on the production build and writes
one report line per target to test-results/perf.json, with a console
table. PERF_SCALE=small (default) shrinks volumes and durations only;
PERF_SCALE=full runs the reference load.

- Ingestion over a pooled transport, real-time delay to the live event,
  wait-for-a-message latency, list and search at scale, start-up time,
  memory (docker stats of trapbox:test or VmRSS), and durability of
  every acknowledged message through a SIGKILL and a restart.
- A resumable seeder writes users, projects, environments and messages
  through the intake and processing pipeline before the instance
  starts; PERF_DATA_DIR keeps the data between runs.
- Manual perf jobs in GitLab, GitHub and Forgejo keep the report as an
  artifact; docs/operations/performance.md and the release checklist
  describe the full run before each minor release.

Refs: tasks/61-performance-validation-suite.md
Refs: tasks/tasks.md
Refs: tasks/tasks.md
The editor applied a change only when the server answered, so a key
pressed before the answer acted on the previous list, and saves that
overlapped could finish in either order: adding a tag then removing it
quickly could remove every tag. Each change now shows at once, and a
change made while a save runs is sent after it, the latest only.

Refs: tasks/tasks.md
`freePort()` releases the port it probed before the instance binds it,
so a test file running in parallel can take it first: the instance then
exits with EADDRINUSE and the test fails for no fault of the code under
test. The helper now retries on newly chosen ports, up to three times,
unless the test fixed its ports.

Refs: tasks/tasks.md
Refs: tasks/tasks.md
Refs: tasks/tasks.md
Refs: tasks/tasks.md
A data directory Trapbox cannot create (permission denied, read-only
file system, a file in the way) stopped the start with a raw stack
trace. It is now reported as a configuration error naming the variable
to set, like every other invalid setting.
Without TRAPBOX_PUBLIC_URL, Trapbox uses http://localhost with its HTTP
port; without TRAPBOX_DATA_DIR, a `trapbox` directory in the system's
temporary directory. Start-up warns when either default applies. The
image still sets TRAPBOX_DATA_DIR=/data.

While the first administrator still has the password generated at the
first start, the sign-in page says that the email address and password
were printed in the logs; the notice goes away once the password is
changed (`admin_password_in_logs` in GET /api/v1/instance).

The README, the configuration table of the specification and a new
.env.example describe the defaults.
update(caniemail): Update caniemail
Some checks failed
CI / docker (pull_request) Failing after 28s
CI / quality (pull_request) Failing after 50s
CI / e2e (chromium) (pull_request) Has been skipped
CI / e2e (webkit) (pull_request) Has been skipped
CI / e2e (firefox) (pull_request) Has been skipped
6f565c0cbb
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Alchemic.Studio/Trapbox!1
No description provided.