openvibers/openvibe.mail Commits

OpenVibe.Mail M1: receive-only Stalwart, masks, burners and agent inboxes (#1)

OP OpenVibers committed 6 days ago · f6c0f20acc · 59 files, +7399 −0

* OpenVibe.Mail M1: masks, burners, agent inboxes and receive-only Stalwart

* CI: pin the shared security workflow at a published commit; allow manual dispatch

* CI: use the public legacy MinIO image (upstream Docker Hub now requires auth)

* Docs: the recovery admin and the service admin are the same account

* Mail: isolate every burner by its own Stalwart account; cap live anonymous burners

* Tests: prove burner isolation and forged-header rejection on real Stalwart

* Tests: keep inbound throttles production-only; disable them in the acceptance harness

* Docs: the service no longer creates a pool mailbox

* Anonymous burners: make the global cap a hard, serialized decision

* Purge: clear the directory cache for every due burner, even on a failed delete

* Docs: name the settings rows the service actually keeps

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..565d230
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,66 @@
++# OpenVibe.Mail — environment (production: /etc/openvibe/mail.env). Names only; never commit values.
++
++NODE_ENV=production
++PORT=5250
++HOST=127.0.0.1
++BASE_URL=https://openvibe.email
++TRUST_PROXY=2
++
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,
++# the owner on a direct connection (migrations), and Valkey (per-address rate counters).
++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=ov:mail:
++#MAIL_PGLITE_DIR=./data/pglite
++
++# OpenVibe.Network: the person's token is verified offline against the JWKS; a typed token (a `typ` or `purpose`
++# claim) is not a person's session and is refused.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# OV_NETWORK_ISSUER=https://openvibe.network
++# OV_SESSION_AUDIENCE=openvibe.network
++# MAIL_AUDIENCE=openvibe.mail
++
++# The addresses. Masks live on their own domain, burners rotate over the subdomains below, and an agent's inbox is
++# <agent>.<user>@<base domain>. A new burner domain needs no code: add it here and restart.
++MAIL_BASE_DOMAIN=openvibe.email
++MAIL_MASK_DOMAIN=mask.openvibe.email
++BURNER_DOMAINS=burn1.openvibe.email,burn2.openvibe.email,burn3.openvibe.email
++
++# Stalwart (the mail server itself), reached over loopback only. The admin credential is used for the management
++# API (domains, cache invalidation) and to read a person's mailbox over JMAP.
++STALWART_URL=http://127.0.0.1:8080
++STALWART_ADMIN_USER=admin
++STALWART_ADMIN_PASSWORD=
++
++# Stalwart's own settings (deploy/stalwart/config.toml reads the same names).
++STALWART_DB_HOST=127.0.0.1
++STALWART_DB_PORT=5432
++STALWART_DB_NAME=ov_mail
++STALWART_DB_USER=
++STALWART_DB_PASSWORD=
++STALWART_BLOB_ENDPOINT=
++STALWART_BLOB_BUCKET=
++STALWART_BLOB_ACCESS_KEY_ID=
++STALWART_BLOB_SECRET_KEY=
++STALWART_BLOB_REGION=us-east-1
++
++# OpenVibe.Events: the outbox relay publishes mail.received and mail.mask.created. Unset keeps the rows in the
++# outbox and status() says the relay is off.
++MAIL_EVENTS_URL=
++MAIL_EVENTS_CLIENT_ID=mail
++MAIL_EVENTS_CLIENT_SECRET=
++#MAIL_EVENTS_INTERVAL_MS=5000
++
++# Caps (server/domain/limits.js).
++#MAIL_MAX_MASKS=20
++#MAIL_MAX_LIVE_BURNERS=5
++# A global ceiling on anonymous burners alive at once: 503 mail.burner_capacity past it.
++#MAIL_MAX_LIVE_ANON_BURNERS=2000
++# Per IP: ten anonymous burners an hour, each living at most ten minutes.
++#MAIL_BURNERS_PER_IP_HOUR=10
++#MAIL_ANON_BURNER_TTL_MINUTES=10
++#MAIL_API_RATE_LIMIT_PER_MIN=240
++#MAIL_PURGE_INTERVAL_MS=60000
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..ba170e9
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,73 @@
++name: ci
++
++on:
++ push:
++ branches: [main]
++ pull_request:
++ workflow_dispatch:
++
++permissions:
++ contents: read
++
++jobs:
++ test:
++ name: unit and Stalwart acceptance
++ runs-on: ubuntu-24.04
++ services:
++ postgres:
++ image: postgres:18-alpine
++ env:
++ POSTGRES_USER: ov
++ POSTGRES_PASSWORD: ov-ci-password
++ POSTGRES_DB: ov_mail
++ ports:
++ - 5432:5432
++ options: >-
++ --health-cmd "pg_isready -U ov -d ov_mail"
++ --health-interval 5s
++ --health-timeout 5s
++ --health-retries 30
++ minio:
++ image: bitnamilegacy/minio:2025.5.24-debian-12-r5
++ env:
++ MINIO_ROOT_USER: ovminio
++ MINIO_ROOT_PASSWORD: ovminiopw
++ ports:
++ - 9000:9000
++ options: >-
++ --health-cmd "curl -fsS http://localhost:9000/minio/health/live"
++ --health-interval 5s
++ --health-timeout 5s
++ --health-retries 30
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - name: syntax
++ run: |
++ find server scripts test deploy -name '*.js' -not -path '*/node_modules/*' -print0 \
++ | xargs -0 -n1 node --check
++ - name: unit tests (PGlite, no services)
++ run: npm test
++ - name: download the pinned Stalwart (sha256 verified)
++ run: node scripts/fetch-stalwart.js
++ - name: Stalwart acceptance (PostgreSQL + MinIO + real SMTP)
++ env:
++ STALWART_BIN: ${{ github.workspace }}/.cache/stalwart/stalwart
++ STALWART_DB_HOST: 127.0.0.1
++ STALWART_DB_PORT: 5432
++ STALWART_DB_USER: ov
++ STALWART_DB_PASSWORD: ov-ci-password
++ STALWART_TEST_DB: ov_mail_test
++ STALWART_BLOB_ENDPOINT: http://127.0.0.1:9000
++ STALWART_BLOB_BUCKET: ov-mail
++ STALWART_BLOB_REGION: us-east-1
++ STALWART_BLOB_ACCESS_KEY_ID: ovminio
++ STALWART_BLOB_SECRET_KEY: ovminiopw
++ run: npm run test:stalwart
++
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..5c1cc3f
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,9 @@
++node_modules/
++data/
++.cache/
++.env
++.env.local
++*.local.js
++npm-debug.log*
++.DS_Store
++deploy/stalwart/rendered/
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..211b446
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,128 @@
++# OpenVibe.Mail
++
++`openvibe.email` — masks that protect you, burners that self-destruct, and an inbox for every agent.
++
++M1 is **receive only**: Stalwart Community listens on port 25, the service hands out addresses that resolve into
++OpenVibe mailboxes, and nothing here sends or relays mail. Sending, inbox AI and paid custom domains are M2–M4 in
++the OpenVibe Seven Domains plan (2026-11-07) §8.
++
++## What is in M1
++
++| Piece | What it does |
++|---|---|
++| **Masks** | `[email protected]` — a per-site address that delivers into your OpenVibe mailbox and nowhere else. Turn it off and it bounces. |
++| **Burners** | `[email protected]` — receive-only, expires after 10 minutes, 1 hour, 1 day or 7 days, or after the first message. A 10-minute burner needs no account. Its mail is deleted when it expires. |
++| **Agent inboxes** | `[email protected]` — an address an agent can read, on the person's own domain. |
++| **Directory** | Stalwart's SQL directory reads `mail_directory_accounts`: every unexpired burner is its **own** account (the burner address is the primary address), so one burner's mail can never be read in another's. Masks and agent inboxes are aliases of the owner's mailbox. A disabled mask or an expired burner resolves to no row and the mail bounces. |
++
++The service stores only the addresses and metadata: message bodies are read from Stalwart over loopback JMAP when a
++code is asked for, and are never written to a log, an event or the database. A burner's account is read with no
++recipient filtering at all; a mask/agent inbox in the shared person mailbox is matched only against the first
++`Delivered-To` header, the one Stalwart stamps — never a sender-written To, Cc or Delivered-To.
++
++## API
++
++All routes are under `/api/v1` and take an OpenVibe.Network token (`Authorization: Bearer …`). An app token must
++name the person it acts for (`on_behalf_of`) and carry `mail.addresses.write` or `mail.messages.read`. A typed token
++(a `typ` or `purpose` claim) is refused.
++
++| Method | Route | Who |
++|---|---|---|
++| `POST` | `/masks` | a person; `{ label, purpose }` |
++| `GET` | `/masks` | a person |
++| `GET` | `/masks/:id` | the owner; anyone else gets 404 |
++| `DELETE` | `/masks/:id` | the owner; turns the mask off |
++| `POST` | `/burners` | a person, or nobody (`{ expires: "10m" \| "1h" \| "1d" \| "7d", first_message_only }`) |
++| `GET` | `/burners` | a person |
++| `GET` | `/burners/:id/messages` | the owner, or the holder of the returned `x-mail-burner-token` |
++| `GET` | `/burners/:id/code` | the same; the newest verification code, extracted from text or HTML |
++| `POST` | `/agent-inboxes` | a person; `{ agent_id }` |
++| `GET` | `/agent-inboxes` | a person |
++| `GET` | `/agent-inboxes/:id/messages` | the owner |
++
++Somebody else's mask, burner or inbox answers **404**, never 403: the answer must not say it exists. The messages
++API returns an array of `{ id, from, subject, received_at }` and nothing else: no preview, no body, no HTML. The
++`/code` endpoint returns `{ code, message_id, received_at, from }`; it extracts the code from text or HTML in
++memory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the liveness
++answer.
++
++## Events
++
++The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:
++
++- `mail.mask.created` — the mask exists;
++- `mail.received` — metadata only: the address, the sender, the message id and the time. **No subject, no body,
++ no code.**
++
++## Contracts
++
++`mail.mask@1`, `mail.burner@1`, `mail.agent-inbox@1` and `mail.message-summary@1` are proposed as an expand-only
++minor of [OpenVibe.Contracts](https://github.com/OpenVibers/OpenVibe.Contracts). The service pins
++`openvibe-contracts` v0.327.0 until that minor is released.
++
++## Stalwart
++
++Stalwart Community is pinned in [`deploy/stalwart/VERSION`](deploy/stalwart/VERSION) (1.0.3, sha256 verified by
++`scripts/fetch-stalwart.js`). Stalwart v0.16 and later stopped reading TOML, so
++[`deploy/stalwart/config.toml`](deploy/stalwart/config.toml) is **our** declarative source of truth:
++
++```sh
++node deploy/stalwart/render.js # → rendered/config.json (the data store) + rendered/plan.json
++node deploy/stalwart/apply.js # reconciles the plan over loopback JMAP
++```
++
++The plan creates the SQL directory and its queries, the base/mask/burner domains (all `allowRelaying = false`), the
++S3 blob store, the built-in spam filter, and exactly three listeners: SMTP on 25, JMAP/management and IMAP on
++loopback. Every default submission/HTTPS/POP3/Sieve listener is removed. The authentication defaults give a
++directory-created account the `User` role — without it Stalwart creates the account and then refuses its own mail.
++
++## Tests
++
++```sh
++npm test # unit: PGlite, an in-process Network and an in-memory mail server
++npm run test:pg # the same through the PostgreSQL container
++npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL + MinIO
++```
++
++The acceptance run (also `test/stalwart/integration.test.js` in CI) starts the pinned binary in recovery mode so
++its default listeners do not collide with the test ports, applies the plan, restarts it on the plan's listeners, and
++checks:
++
++- SMTP to a burner lands, `/code` returns the code, and no body or code reaches the service log;
++- a message to burner A with forged To, Cc and Delivered-To naming burner B never appears in B's messages or
++ `/code`;
++- 300 messages to burner A do not hide burner B's one message (each burner is its own account);
++- a forged Delivered-To does not move mail between two agent inboxes on one person's mailbox;
++- a first-message-only burner refuses the second message;
++- an expired burner's mail is deleted, its account is removed and the address bounces;
++- mail to a disabled mask bounces;
++- another person gets 404 for a mask, burner and agent inbox;
++- a ten-minute burner works without an account and is read with its token.
++
++## Layout
++
++```
++server/ the API, domains, Stalwart client, events and the due-based purge job
++migrations/ NNNN_*.sql (expand-only), including the directory view Stalwart reads
++deploy/stalwart/ config.toml, VERSION, render.js, apply.js
++deploy/systemd/ the memory-capped Stalwart slice and both units
++deploy/init-db.js apply migrations before systemd starts the service
++test/ unit tests and the real-Stalwart acceptance test
++docs/ INSTALL.md and DNS.md
++```
++
++## Limits
++
++A person has 20 masks and 5 live burners. A 10-minute (account-less) burner is capped at 10 per IP per hour and at
++2,000 live anonymous burners globally; past the global cap the API answers 503 `mail.burner_capacity`. The counters
++live in PostgreSQL, so a restart does not hand out a fresh allowance.
++
++## Install
++
++See [`docs/INSTALL.md`](docs/INSTALL.md) and [`docs/DNS.md`](docs/DNS.md). M1 is **not deployed** from this
++repository; Opus installs it on the host.
++
++## Licence
++
++AGPL-3.0-only, matching Stalwart Community. OpenVibe.Mail ships configuration and a Node service, not a modified
++Stalwart.
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..1d4c54b
---- /dev/null
++++ b/SECURITY.md
@@@ -0,0 +1,34 @@
++# Security
++
++OpenVibe.Mail handles addresses, mail metadata and — for a few seconds, in memory — a verification code. It does
++not store message bodies. Treat a report about any of the following as security-sensitive.
++
++## Reporting
++
++Report a vulnerability to **[email protected]**. Do not open a public issue for a suspected vulnerability.
++Include the affected route, a minimal reproduction and what an attacker gains. We will acknowledge and keep you
++informed; do not test against production mailboxes that are not yours.
++
++## What M1 runs
++
++- Stalwart Community receives SMTP on port 25. There is no submission listener (587/465), no POP3, no Sieve and no
++ public HTTPS listener; JMAP/management (8080) and IMAP (143) bind loopback only.
++- Every domain is created with `allowRelaying = false`; an unauthenticated connection cannot relay.
++- The API is behind OpenVibe.Network tokens. A typed token (`typ` or `purpose`) is refused. Another person's mask,
++ burner or inbox answers 404, not 403.
++- A burner made without an account is limited per IP and capped, and its mail is deleted when it expires.
++- Stalwart runs in its own systemd slice with `MemoryMax=1G`, `CPUQuota=100%`, `NoNewPrivileges=true` and
++ `ProtectSystem=strict`; the Node service is read-only too.
++- Secrets live in `/etc/openvibe/*.env` (root, 0600). The rendered Stalwart `config.json` names the database
++ password by environment variable; the S3 keys are read from the environment by `apply.js` and are never written
++ to a rendered file or a log.
++
++## Out of scope for M1
++
++Sending, DKIM signing, inbox AI, paid custom domains and the public web UI are later phases. A report that a
++receive-only domain has no SPF sending path is not a vulnerability; `v=spf1 -all` is deliberate.
++
++## Secrets in the repository
++
++There are none. `.env.example` names variables without values. CI uses throwaway service containers. If a secret
++is ever committed, rotate it first, then remove it from history; deleting the file is not enough.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..d0b5484
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,64 @@
++{
++ "repository": "OpenVibers/OpenVibe.Mail",
++ "stage": "alpha",
++ "code": true,
++ "wave": "M1",
++ "plan": "OpenVibe Seven Domains Plan (2026-11-07) §8, M1: Stalwart receiving, masks, burners and agent inboxes",
++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db), Stalwart Community 1.0.3 (AGPL-3.0-only), S3-compatible blobs",
++ "port": 5250,
++ "domain": "openvibe.email; receive-only MX. The API is loopback and has no public site in M1.",
++ "contracts": "openvibe-contracts v0.327.0",
++ "contractsProposed": [
++ "mail.mask@1",
++ "mail.burner@1",
++ "mail.agent-inbox@1",
++ "mail.message-summary@1"
++ ],
++ "sdk": "openvibe-sdk v0.97.1",
++ "shared": "openvibe-shared v4.135.0",
++ "stalwart": {
++ "version": "1.0.3",
++ "asset": "stalwart-x86_64-unknown-linux-gnu.tar.gz",
++ "sha256": "8ae481ff410cd09dd53d7f49f528dcad35d90b4b2ffb0c49c9ae23aaf43c4831",
++ "dataStore": "PostgreSQL (database ov_mail)",
++ "blobStore": "S3-compatible (Backblaze B2 in production, MinIO in CI)",
++ "listeners": "SMTP 25 inbound; JMAP/management 8080 and IMAP 143 on loopback; no submission, POP3, Sieve or HTTPS listener",
++ "spamFilter": "built-in, on",
++ "directory": "SQL over mail_directory_accounts: every burner is its own account (name = burner address); masks and agent inboxes are aliases of the owner's mailbox"
++ },
++ "capabilities": [],
++ "capabilitiesRegistered": false,
++ "eventsProduced": [
++ "mail.mask.created",
++ "mail.received"
++ ],
++ "eventsPayload": "metadata only; mail.received never carries a subject, body or code",
++ "limits": {
++ "masksPerPerson": 20,
++ "liveBurnersPerPerson": 5,
++ "accountlessBurnersPerIpPerHour": 10,
++ "accountlessBurnerTtl": "10m",
++ "liveAnonymousBurnersGlobal": 2000
++ },
++ "privacy": [
++ "a burner's mail is isolated by its own Stalwart account, never by sender-written To/Cc/Delivered-To headers",
++ "the shared person mailbox matches only the first Delivered-To header Stalwart stamps",
++ "message bodies are never logged, stored by this service, served as HTML or put in an event; the messages API returns metadata only",
++ "a mask never forwards outside OpenVibe",
++ "the code extractor is the only reader of a body and drops it immediately",
++ "an expired burner's mail and account are deleted by a due-based purge job; a failure keeps the row for the next run"
++ ],
++ "tests": {
++ "unit": "node test/run.js (PGlite, in-process Network and mail server)",
++ "postgres": "MAIL_TEST_STORE=pg node test/run.js",
++ "acceptance": "node test/stalwart/run.js (pinned Stalwart + PostgreSQL + MinIO; SMTP delivery, code, forged To/Cc/Delivered-To isolation, 300-message isolation, expiry account deletion and bounce, disabled-mask bounce, 404s, no body in logs)"
++ },
++ "deployed": false,
++ "deployment": "M1 is deliberately not deployed by this job. Opus installs it on the host; docs/INSTALL.md has the steps and docs/DNS.md the records.",
++ "security": [
++ "the API is behind OpenVibe.Network auth; typed tokens are refused",
++ "someone else's address is 404, never 403",
++ "Stalwart runs in its own systemd slice: MemoryMax=1G, CPUQuota=100%, NoNewPrivileges, ProtectSystem=strict",
++ "the mail server has no password-authenticated listener and no relay: domains are allowRelaying=false and the submission/POP3/Sieve/HTTPS listeners are removed"
++ ]
++}
ddiff --git a/deploy/init-db.js b/deploy/init-db.js
nnew file mode 100644
iindex 0000000..d83ae31
---- /dev/null
++++ b/deploy/init-db.js
@@@ -0,0 +1,24 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Apply migrations/NNNN_*.sql to the database named by DATABASE_DIRECT_URL, then exit. The service also migrates at
++ * boot; this is the install step that makes a failure visible before systemd starts the unit.
++ */
++const config = require('../server/config').load();
++const { openDb } = require('../server/db');
++
++async function main() {
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set');
++ const db = await openDb(config);
++ try {
++ process.stdout.write('migrations applied\n');
++ } finally {
++ await db.close();
++ }
++}
++
++main().catch((err) => {
++ process.stderr.write(`init-db failed: ${err && err.message ? err.message : err}\n`);
++ process.exit(1);
++});
ddiff --git a/deploy/stalwart/VERSION b/deploy/stalwart/VERSION
nnew file mode 100644
iindex 0000000..7964627
---- /dev/null
++++ b/deploy/stalwart/VERSION
@@@ -0,0 +1,6 @@
++# Stalwart Community, pinned. The binary is AGPL-3.0-only; OpenVibe.Mail ships the configuration, not a fork.
++version=1.0.3
++release=https://github.com/stalwartlabs/stalwart/releases/tag/v1.0.3
++asset=stalwart-x86_64-unknown-linux-gnu.tar.gz
++sha256=8ae481ff410cd09dd53d7f49f528dcad35d90b4b2ffb0c49c9ae23aaf43c4831
++url=https://github.com/stalwartlabs/stalwart/releases/download/v1.0.3/stalwart-x86_64-unknown-linux-gnu.tar.gz
ddiff --git a/deploy/stalwart/apply.js b/deploy/stalwart/apply.js
nnew file mode 100644
iindex 0000000..acb0d4a
---- /dev/null
++++ b/deploy/stalwart/apply.js
@@@ -0,0 +1,289 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Apply the rendered Stalwart plan over loopback JMAP.
++ *
++ * Stalwart v1.0.3 keeps its settings as JMAP objects (the v0.16 change), so "configuration" is a reconciliation:
++ * directory the SQL views in migrations/0002_directory_views.sql
++ * authentication points at that directory
++ * domains the base, mask and burner domains, none allowed to relay
++ * system the SMTP hostname and the default domain
++ * blob store S3-compatible (B2 in production); the keys are read from the environment here, never written
++ * spam the built-in filter, on
++ * listeners SMTP on 25; JMAP/management and IMAP on loopback; every submission/POP3/Sieve/HTTPS listener gone
++ *
++ * STALWART_URL=http://127.0.0.1:8080 STALWART_ADMIN_PASSWORD=… \
++ * node deploy/stalwart/apply.js [--plan deploy/stalwart/rendered/plan.json]
++ *
++ * It is idempotent: run it on install and after a configuration change.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
++
++function arg(name, fallback) {
++ const i = process.argv.indexOf(name);
++ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
++}
++
++function basicAuth(user, password) {
++ return 'Basic ' + Buffer.from(`${user}:${password}`, 'utf8').toString('base64');
++}
++
++function createClient({ url, user, password, timeoutMs = 20_000 }) {
++ const base = String(url || '').replace(/\/+$/, '');
++ const auth = basicAuth(user, password);
++
++ async function jmap(methodCalls) {
++ const res = await fetch(`${base}/jmap`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: auth },
++ body: JSON.stringify({ using: USING, methodCalls }),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ const text = await res.text();
++ let parsed = null;
++ try { parsed = JSON.parse(text); } catch { /* the message below is the useful one */ }
++ if (!res.ok || !parsed || !Array.isArray(parsed.methodResponses)) {
++ throw new Error(`the mail server refused a JMAP request (HTTP ${res.status}): ${(parsed && parsed.detail) || text.slice(0, 200)}`);
++ }
++ return parsed.methodResponses;
++ }
++
++ async function one(name, args, id = '0') {
++ const [[resultName, result]] = await jmap([[name, args, id]]);
++ if (resultName === 'error') throw new Error(`${name}: ${result.description || result.type}`);
++ return result;
++ }
++
++ return { jmap, one };
++}
++
++function setProblems(label, result) {
++ const problems = [];
++ for (const key of ['notCreated', 'notUpdated', 'notDestroyed']) {
++ for (const [id, why] of Object.entries((result && result[key]) || {})) {
++ problems.push(`${label} ${key} ${id}: ${why.description || why.type || 'refused'}`);
++ }
++ }
++ return problems;
++}
++
++async function apply(plan, client, env = process.env) {
++ const problems = [];
++
++ // ---- directory -------------------------------------------------------------------------
++ const dirQuery = await client.one('x:Directory/query', { filter: {}, limit: 50 }, 'dq');
++ const dirIds = dirQuery.ids || [];
++ const dirs = dirIds.length ? (await client.one('x:Directory/get', { ids: dirIds }, 'dg')).list || [] : [];
++ const existingDir = dirs.find((d) => d.description === plan.directory.description);
++ const directory = {
++ '@type': 'Sql',
++ description: plan.directory.description,
++ store: { '@type': 'Default' },
++ columnEmail: 'name',
++ columnSecret: 'secret',
++ columnDescription: 'description',
++ queryRecipient: plan.directory.query_recipient,
++ queryLogin: plan.directory.query_login,
++ queryMemberOf: plan.directory.query_member_of,
++ queryEmailAliases: plan.directory.query_email_aliases,
++ };
++ let directoryId = existingDir && existingDir.id;
++ if (directoryId) {
++ const r = await client.one('x:Directory/set', { update: { [directoryId]: directory } }, 'du');
++ problems.push(...setProblems('directory', r));
++ } else {
++ const r = await client.one('x:Directory/set', { create: { openvibe: directory } }, 'dc');
++ problems.push(...setProblems('directory', r));
++ directoryId = r.created && r.created.openvibe && r.created.openvibe.id;
++ }
++ if (!directoryId) throw new Error('the mail server did not give the SQL directory an id');
++
++ // Accounts created from the SQL directory inherit the Authentication defaults. A fresh Stalwart has the four
++ // built-in roles but an empty default set; without the User role an account is created and then refuses its
++ // own mail with "This account is not authorized to receive email." The ids are found by description because
++ // they are generated per database.
++ const roleQuery = await client.one('x:Role/query', { filter: {}, limit: 50 }, 'rq');
++ const roleList = roleQuery.ids.length
++ ? (await client.one('x:Role/get', { ids: roleQuery.ids, properties: ['id', 'description'] }, 'rg')).list || []
++ : [];
++ const role = (description) => {
++ const found = roleList.find((r) => String(r.description || '').toLowerCase() === description.toLowerCase());
++ return found ? found.id : null;
++ };
++ const userRole = role('User');
++ const groupRole = role('Group');
++ const tenantRole = role('Tenant Administrator');
++ const adminRole = role('System Administrator');
++
++ // The built-in roles are inserted with the safe defaults, which a recovery-mode first start has not written
++ // yet. In that one case leave the default role ids alone; the acceptance harness runs apply once more after
++ // the normal start, when they exist.
++ const authUpdate = { directoryId };
++ if (userRole && groupRole && tenantRole && adminRole) {
++ authUpdate.defaultUserRoleIds = { [userRole]: true };
++ authUpdate.defaultGroupRoleIds = { [groupRole]: true };
++ authUpdate.defaultTenantRoleIds = { [tenantRole]: true, [userRole]: true };
++ authUpdate.defaultAdminRoleIds = { [adminRole]: true, [userRole]: true };
++ }
++ const auth = await client.one('x:Authentication/set', { update: { singleton: authUpdate } }, 'a');
++ problems.push(...setProblems('authentication', auth));
++ if (authUpdate.defaultUserRoleIds) {
++ // The running process parsed Authentication at startup; reload it so an account the directory creates on
++ // the next delivery inherits the User role instead of being created without permissions.
++ const reload = await client.one('x:Action/set', { create: { reload: { '@type': 'ReloadSettings' } } }, 'rs').catch((err) => ({ notCreated: { reload: { description: err.message } } }));
++ problems.push(...setProblems('reload settings', reload));
++ }
++
++ // ---- domains ---------------------------------------------------------------------------
++ const domainQuery = await client.one('x:Domain/query', { filter: {}, limit: 500 }, 'dq');
++ const domainIds = domainQuery.ids || [];
++ const domains = domainIds.length
++ ? (await client.one('x:Domain/get', { ids: domainIds }, 'dg')).list || []
++ : [];
++ const byName = new Map(domains.map((d) => [String(d.name || '').toLowerCase(), d]));
++ for (const name of plan.domains.all) {
++ const domain = String(name || '').toLowerCase();
++ if (byName.has(domain)) continue;
++ const r = await client.one('x:Domain/set', {
++ create: { [domain]: { '@type': 'Domain', name: domain, enable: true, allowRelaying: false } },
++ }, 'ds');
++ problems.push(...setProblems(`domain ${domain}`, r));
++ const id = r.created && r.created[domain] && r.created[domain].id;
++ if (id) byName.set(domain, { id, name: domain });
++ }
++ const baseDomain = byName.get(String(plan.domains.base).toLowerCase());
++ if (!baseDomain) throw new Error(`the base domain ${plan.domains.base} is not known to the mail server`);
++
++ const system = await client.one('x:SystemSettings/set', {
++ update: {
++ singleton: {
++ defaultHostname: plan.server.default_hostname,
++ defaultDomainId: baseDomain.id,
++ },
++ },
++ }, 's');
++ problems.push(...setProblems('system', system));
++
++ // ---- blob store ------------------------------------------------------------------------
++ const blob = plan.blob_store;
++ const accessKeyId = env[blob.accessKeyIdEnv];
++ const secretKey = env[blob.secretKeyEnv];
++ if (!accessKeyId || !secretKey) {
++ throw new Error(`the blob store needs ${blob.accessKeyIdEnv} and ${blob.secretKeyEnv}`);
++ }
++ const blobStore = {
++ '@type': 'S3',
++ region: { '@type': 'Custom', url: blob.endpoint, region: blob.region || 'us-east-1' },
++ bucket: blob.bucket,
++ accessKeyId: { '@type': 'Value', value: accessKeyId },
++ secretKey: { '@type': 'Value', secret: secretKey },
++ sessionToken: { '@type': 'None' },
++ timeout: blob.timeoutMs,
++ maxRetries: blob.maxRetries,
++ keyPrefix: blob.keyPrefix || undefined,
++ allowInvalidCerts: Boolean(blob.allowInvalidCerts),
++ verifyAfterWrite: blob.verifyAfterWrite !== false,
++ };
++ const blobResult = await client.one('x:BlobStore/set', { update: { singleton: blobStore } }, 'b');
++ problems.push(...setProblems('blob store', blobResult));
++
++ // ---- spam ------------------------------------------------------------------------------
++ const spam = await client.one('x:SpamSettings/set', {
++ update: { singleton: { enable: Boolean(plan.spam.enabled), scoreSpam: plan.spam.score_spam } },
++ }, 'sp');
++ problems.push(...setProblems('spam', spam));
++
++ // M1 has no submission listener: every SMTP connection is an inbound relay from another mail server. Stalwart's
++ // default is `local_port != 25`, which would demand authentication on any test or alternate port; state the
++ // invariant (inbound does not authenticate) instead of relying on the port number. Relaying is still refused by
++ // MtaStageRcpt.allowRelaying and by the domains' allowRelaying = false.
++ const mtaAuth = await client.one('x:MtaStageAuth/set', {
++ update: { singleton: { require: { match: {}, else: 'false' } } },
++ }, 'ma');
++ problems.push(...setProblems('mta auth', mtaAuth));
++
++ // A debug tracer is opt-in (STALWART_DEBUG=1) and exists for the acceptance test's failure output. It is
++ // created in the recovery phase so the normal phase's SMTP sessions are logged.
++ if (String(env.STALWART_DEBUG || '') === '1') {
++ const existingTracer = await client.one('x:Tracer/query', { filter: {}, limit: 10 }, 'tq').catch(() => ({ ids: [] }));
++ if (!(existingTracer.ids || []).length) {
++ const tracer = await client.one('x:Tracer/set', {
++ create: {
++ stdout: {
++ '@type': 'Stdout',
++ enable: true,
++ level: 'debug',
++ ansi: false,
++ multiline: true,
++ lossy: true,
++ },
++ },
++ }, 'tr');
++ problems.push(...setProblems('tracer', tracer));
++ }
++ }
++
++ // ---- listeners -------------------------------------------------------------------------
++ const listenerQuery = await client.one('x:NetworkListener/query', { filter: {}, limit: 100 }, 'lq');
++ const listenerIds = listenerQuery.ids || [];
++ const listeners = listenerIds.length
++ ? (await client.one('x:NetworkListener/get', { ids: listenerIds }, 'lg')).list || []
++ : [];
++ const byListener = new Map(listeners.map((l) => [String(l.name || '').toLowerCase(), l]));
++ const wanted = new Set(plan.listeners.map((l) => l.name));
++
++ const stale = listeners.filter((l) => !wanted.has(String(l.name || '').toLowerCase())).map((l) => l.id);
++ if (stale.length) {
++ const r = await client.one('x:NetworkListener/set', { destroy: stale }, 'ld');
++ problems.push(...setProblems('listener destroy', r));
++ }
++
++ for (const l of plan.listeners) {
++ const bind = {};
++ for (const address of l.bind) bind[address] = true;
++ const value = {
++ protocol: l.protocol,
++ bind,
++ useTls: Boolean(l.use_tls),
++ implicitTls: Boolean(l.implicit_tls),
++ };
++ const found = byListener.get(l.name);
++ const r = found
++ ? await client.one('x:NetworkListener/set', { update: { [found.id]: value } }, `lu-${l.name}`)
++ : await client.one('x:NetworkListener/set', {
++ create: { [l.name]: { name: l.name, ...value } },
++ }, `lc-${l.name}`);
++ problems.push(...setProblems(`listener ${l.name}`, r));
++ }
++
++ if (problems.length) {
++ throw new Error(`the mail server did not take every setting:\n ${problems.join('\n ')}`);
++ }
++ return { directoryId, domains: [...byName.keys()], listeners: plan.listeners.map((l) => l.name) };
++}
++
++async function main() {
++ if (!process.env.STALWART_ADMIN_PASSWORD) throw new Error('STALWART_ADMIN_PASSWORD is not set');
++ const planPath = path.resolve(arg('--plan', process.env.STALWART_PLAN || path.join(__dirname, 'rendered', 'plan.json')));
++ const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'));
++ const client = createClient({
++ url: process.env.STALWART_URL || 'http://127.0.0.1:8080',
++ user: process.env.STALWART_ADMIN_USER || 'admin',
++ password: process.env.STALWART_ADMIN_PASSWORD,
++ });
++ const summary = await apply(plan, client);
++ process.stdout.write(`applied ${path.basename(planPath)}: directory ${summary.directoryId}, ${summary.domains.length} domains, listeners ${summary.listeners.join(', ')}\n`);
++}
++
++if (require.main === module) {
++ main().catch((err) => {
++ process.stderr.write(`apply failed: ${err && err.message ? err.message : err}\n`);
++ process.exit(1);
++ });
++}
++
++module.exports = { apply, createClient };
ddiff --git a/deploy/stalwart/config.toml b/deploy/stalwart/config.toml
nnew file mode 100644
iindex 0000000..fc6c6dc
---- /dev/null
++++ b/deploy/stalwart/config.toml
@@@ -0,0 +1,73 @@
++# OpenVibe.Mail — Stalwart Community, M1: receive only.
++#
++# Stalwart v0.16 and later deliberately stopped reading TOML (see UPGRADING/v0_16.md in the pinned release): the
++# server reads one small config.json that names its data store, and keeps every other setting as a JMAP object.
++# This file is therefore our source of truth, not Stalwart's:
++#
++# node deploy/stalwart/render.js → rendered/config.json (the data store Stalwart reads at boot)
++# → rendered/plan.json (directory, domains, blob store, spam, listeners)
++# node deploy/stalwart/apply.js reconciles plan.json over loopback JMAP
++#
++# An STALWART_* environment variable overrides the matching value at render time; secrets are named here and
++# resolved by apply.js, never written to a rendered file.
++
++[server]
++default_hostname = "mail.openvibe.email"
++
++[domains]
++# MAIL_BASE_DOMAIN, MAIL_MASK_DOMAIN and BURNER_DOMAINS override these, so the mail server and the service always
++# agree about the addresses they hand out. A new burner domain is a configuration change, never a code change.
++base = "openvibe.email"
++mask = "mask.openvibe.email"
++burners = ["burn1.openvibe.email", "burn2.openvibe.email", "burn3.openvibe.email"]
++
++[data_store]
++kind = "postgresql"
++host = "127.0.0.1"
++port = 5432
++database = "ov_mail"
++user = "ov_mail"
++# The password is an EnvironmentVariable reference in rendered/config.json, not a value in it.
++password_env = "STALWART_DB_PASSWORD"
++ssl = false
++pool_max_connections = 20
++
++[blob_store]
++# B2 in production (an S3-compatible endpoint), MinIO in CI. The endpoint is a custom S3 region to Stalwart.
++kind = "s3"
++endpoint = "https://s3.eu-central-003.backblazeb2.com"
++bucket = "openvibe-mail"
++region = "eu-central-003"
++access_key_id_env = "STALWART_BLOB_ACCESS_KEY_ID"
++secret_key_env = "STALWART_BLOB_SECRET_KEY"
++allow_invalid_certs = false
++verify_after_write = true
++timeout_ms = 30000
++max_retries = 3
++key_prefix = ""
++
++[directory]
++# Stalwart asks these views who owns an address. The view is migrations/0002_directory_views.sql: a burner that
++# has not expired resolves to its own account (name = the burner address), while a mask or agent inbox resolves to
++# the owner's mailbox and is an alias of it. Everything else resolves to no row, and the mail server bounces it.
++description = "OpenVibe mail directory"
++query_recipient = "SELECT name, secret, description FROM mail_directory_accounts WHERE address = $1"
++query_login = "SELECT name, secret, description FROM mail_directory_accounts WHERE name = $1"
++query_member_of = "SELECT NULL::text AS member_of FROM mail_directory_accounts WHERE name = $1 AND false"
++query_email_aliases = "SELECT address FROM mail_directory_accounts WHERE name = $1 AND address <> $1"
++
++[spam]
++# The built-in filter. M1 receives for real people, so junk is tagged rather than relayed or discarded.
++enabled = true
++score_spam = 5.0
++
++[listeners]
++# SMTP accepts inbound mail on 25. There is deliberately no submission listener (587 or 465) and no relay:
++# the domain is created with allowRelaying = false and no authenticated SMTP path exists.
++smtp_bind = "0.0.0.0"
++smtp_port = 25
++# JMAP (and the management API) is loopback only; so is IMAP. The Node service is the only JMAP client for M1.
++http_bind = "127.0.0.1"
++http_port = 8080
++imap_bind = "127.0.0.1"
++imap_port = 143
ddiff --git a/deploy/stalwart/render.js b/deploy/stalwart/render.js
nnew file mode 100644
iindex 0000000..7529a98
---- /dev/null
++++ b/deploy/stalwart/render.js
@@@ -0,0 +1,171 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Render deploy/stalwart/config.toml into the two files Stalwart v1.0.3 actually consumes:
++ *
++ * config.json the data store the binary reads at boot (one small file; UPGRADING/v0_16.md)
++ * plan.json the settings apply.js reconciles over loopback JMAP
++ *
++ * Nothing secret is written: the data-store password stays an EnvironmentVariable reference, and the S3 keys are
++ * the *names* of environment variables that apply.js resolves in memory.
++ *
++ * node deploy/stalwart/render.js [--config PATH] [--out DIR]
++ */
++const fs = require('fs');
++const path = require('path');
++const { parse } = require('./toml');
++
++const ROOT = path.join(__dirname, '..', '..');
++
++function arg(name, fallback) {
++ const i = process.argv.indexOf(name);
++ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
++}
++
++function envString(name, fallback) {
++ const v = process.env[name];
++ return v === undefined || v === '' ? fallback : String(v).trim();
++}
++
++function envInt(name, fallback) {
++ const v = process.env[name];
++ if (v === undefined || v === '') return fallback;
++ const n = Number(v);
++ if (!Number.isInteger(n) || n < 0) throw new Error(`${name} must be a whole number`);
++ return n;
++}
++
++function envBool(name, fallback) {
++ const v = process.env[name];
++ if (v === undefined || v === '') return fallback;
++ return /^(1|true|yes|on)$/i.test(String(v).trim());
++}
++
++function envList(name, fallback) {
++ const v = process.env[name];
++ if (v === undefined || v === '') return fallback;
++ return String(v).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
++}
++
++function required(value, what) {
++ if (!value) throw new Error(`${what} is empty (see .env.example)`);
++ return value;
++}
++
++function render(config) {
++ const db = config.data_store || {};
++ if (String(db.kind || '').toLowerCase() !== 'postgresql') {
++ throw new Error('config.toml: data_store.kind must be "postgresql" (M1 stores mail in PostgreSQL)');
++ }
++ const host = envString('STALWART_DB_HOST', db.host);
++ const port = envInt('STALWART_DB_PORT', db.port);
++ const database = envString('STALWART_DB_NAME', db.database);
++ const user = envString('STALWART_DB_USER', db.user);
++ const passwordEnv = envString('STALWART_DB_PASSWORD_ENV', db.password_env || 'STALWART_DB_PASSWORD');
++ required(host, 'STALWART_DB_HOST');
++ required(database, 'STALWART_DB_NAME');
++ required(user, 'STALWART_DB_USER');
++
++ const dataStore = {
++ '@type': 'PostgreSql',
++ host,
++ port,
++ database,
++ authUsername: user,
++ authSecret: { '@type': 'EnvironmentVariable', variableName: passwordEnv },
++ useTls: envBool('STALWART_DB_SSL', Boolean(db.ssl)),
++ allowInvalidCerts: false,
++ poolMaxConnections: envInt('STALWART_DB_POOL_MAX', db.pool_max_connections || 20),
++ };
++
++ const blob = config.blob_store || {};
++ const domains = config.domains || {};
++ const base = envString('MAIL_BASE_DOMAIN', domains.base);
++ const mask = envString('MAIL_MASK_DOMAIN', domains.mask);
++ const burners = envList('BURNER_DOMAINS', domains.burners || []);
++ if (!burners.length) throw new Error('BURNER_DOMAINS is empty: a burner needs a domain to live on');
++
++ const smtpBind = envString('STALWART_SMTP_BIND', config.listeners.smtp_bind);
++ const smtpPort = envInt('STALWART_SMTP_PORT', config.listeners.smtp_port);
++ const httpBind = envString('STALWART_HTTP_BIND', config.listeners.http_bind);
++ const httpPort = envInt('STALWART_HTTP_PORT', config.listeners.http_port);
++ const imapBind = envString('STALWART_IMAP_BIND', config.listeners.imap_bind);
++ const imapPort = envInt('STALWART_IMAP_PORT', config.listeners.imap_port);
++
++ const blobStore = {
++ kind: String(blob.kind || '').toLowerCase(),
++ endpoint: envString('STALWART_BLOB_ENDPOINT', blob.endpoint),
++ bucket: envString('STALWART_BLOB_BUCKET', blob.bucket),
++ region: envString('STALWART_BLOB_REGION', blob.region),
++ accessKeyIdEnv: envString('STALWART_BLOB_ACCESS_KEY_ID_ENV', blob.access_key_id_env || 'STALWART_BLOB_ACCESS_KEY_ID'),
++ secretKeyEnv: envString('STALWART_BLOB_SECRET_KEY_ENV', blob.secret_key_env || 'STALWART_BLOB_SECRET_KEY'),
++ allowInvalidCerts: envBool('STALWART_BLOB_ALLOW_INVALID_CERTS', Boolean(blob.allow_invalid_certs)),
++ verifyAfterWrite: envBool('STALWART_BLOB_VERIFY_AFTER_WRITE', blob.verify_after_write !== false),
++ timeoutMs: envInt('STALWART_BLOB_TIMEOUT_MS', blob.timeout_ms || 30000),
++ maxRetries: envInt('STALWART_BLOB_MAX_RETRIES', blob.max_retries || 3),
++ keyPrefix: envString('STALWART_BLOB_KEY_PREFIX', blob.key_prefix || '') || null,
++ };
++ if (blobStore.kind !== 's3') throw new Error('config.toml: blob_store.kind must be "s3" (B2 in production)');
++ required(blobStore.endpoint, 'STALWART_BLOB_ENDPOINT');
++ required(blobStore.bucket, 'STALWART_BLOB_BUCKET');
++
++ const directory = config.directory || {};
++ const spam = config.spam || {};
++
++ return {
++ dataStore,
++ plan: {
++ generated_by: 'deploy/stalwart/render.js',
++ version: 1,
++ server: {
++ default_hostname: envString('STALWART_DEFAULT_HOSTNAME', config.server.default_hostname),
++ },
++ domains: {
++ base,
++ mask,
++ burners,
++ all: [base, mask, ...burners],
++ },
++ directory: {
++ description: directory.description || 'OpenVibe mail directory',
++ query_recipient: directory.query_recipient,
++ query_login: directory.query_login,
++ query_member_of: directory.query_member_of,
++ query_email_aliases: directory.query_email_aliases,
++ },
++ blob_store: blobStore,
++ spam: {
++ enabled: envBool('STALWART_SPAM_ENABLED', spam.enabled !== false),
++ score_spam: Number(envString('STALWART_SPAM_SCORE', spam.score_spam || 5.0)),
++ },
++ listeners: [
++ { name: 'smtp', protocol: 'smtp', bind: [`${smtpBind}:${smtpPort}`], use_tls: true, implicit_tls: false },
++ { name: 'http', protocol: 'http', bind: [`${httpBind}:${httpPort}`], use_tls: false, implicit_tls: false },
++ { name: 'imap', protocol: 'imap', bind: [`${imapBind}:${imapPort}`], use_tls: false, implicit_tls: false },
++ ],
++ },
++ };
++}
++
++function main() {
++ const configPath = path.resolve(arg('--config', path.join(__dirname, 'config.toml')));
++ const outDir = path.resolve(arg('--out', process.env.STALWART_RENDER_DIR || path.join(__dirname, 'rendered')));
++ const config = parse(fs.readFileSync(configPath, 'utf8'));
++ const rendered = render(config);
++ fs.mkdirSync(outDir, { recursive: true });
++ const configOut = path.join(outDir, 'config.json');
++ const planOut = path.join(outDir, 'plan.json');
++ fs.writeFileSync(configOut, `${JSON.stringify(rendered.dataStore, null, 2)}\n`, { mode: 0o600 });
++ fs.writeFileSync(planOut, `${JSON.stringify(rendered.plan, null, 2)}\n`, { mode: 0o600 });
++ process.stdout.write(`rendered ${path.relative(ROOT, configOut)} and ${path.relative(ROOT, planOut)}\n`);
++}
++
++if (require.main === module) {
++ try { main(); } catch (err) {
++ process.stderr.write(`render failed: ${err && err.message ? err.message : err}\n`);
++ process.exit(1);
++ }
++}
++
++module.exports = { render };
ddiff --git a/deploy/stalwart/toml.js b/deploy/stalwart/toml.js
nnew file mode 100644
iindex 0000000..2ce2ce4
---- /dev/null
++++ b/deploy/stalwart/toml.js
@@@ -0,0 +1,120 @@
++'use strict';
++
++/**
++ * The small TOML reader OpenVibe.Mail's Stalwart configuration needs.
++ *
++ * The file is ours, not Stalwart's (v0.16+ stopped reading TOML), so this reader supports exactly the TOML the
++ * file uses: tables, string/integer/float/boolean values and one-line arrays. It does not pretend to be a general
++ * TOML parser; a line it does not understand is an error with its line number, never a silently ignored setting.
++ */
++
++function fail(line, message) {
++ throw new Error(`config.toml:${line}: ${message}`);
++}
++
++/** Remove a `#` comment that is not inside a quoted string. */
++function stripComment(line) {
++ let quote = null;
++ for (let i = 0; i < line.length; i++) {
++ const c = line[i];
++ if (quote) {
++ if (c === '\\' && quote === '"') i += 1;
++ else if (c === quote) quote = null;
++ } else if (c === '"' || c === "'") {
++ quote = c;
++ } else if (c === '#') {
++ return line.slice(0, i);
++ }
++ }
++ return line;
++}
++
++function unescape(value) {
++ let out = '';
++ for (let i = 0; i < value.length; i++) {
++ const c = value[i];
++ if (c !== '\\') { out += c; continue; }
++ const next = value[++i];
++ if (next === 'n') out += '\n';
++ else if (next === 't') out += '\t';
++ else if (next === 'r') out += '\r';
++ else if (next === '"') out += '"';
++ else if (next === '\\') out += '\\';
++ else fail(0, `unknown escape \\${next}`);
++ }
++ return out;
++}
++
++function parseValue(raw, line) {
++ const text = String(raw).trim();
++ if (!text) fail(line, 'a value is missing');
++ if (text.startsWith('"')) {
++ if (!text.endsWith('"') || text.length < 2) fail(line, 'an unterminated string');
++ return unescape(text.slice(1, -1));
++ }
++ if (text.startsWith("'")) {
++ if (!text.endsWith("'") || text.length < 2) fail(line, 'an unterminated string');
++ return text.slice(1, -1);
++ }
++ if (text.startsWith('[')) {
++ if (!text.endsWith(']')) fail(line, 'an unterminated array');
++ const inner = text.slice(1, -1).trim();
++ if (!inner) return [];
++ return splitArray(inner, line).map((item) => parseValue(item, line));
++ }
++ if (text === 'true') return true;
++ if (text === 'false') return false;
++ if (/^-?\d+$/.test(text)) return Number(text);
++ if (/^-?\d+\.\d+$/.test(text)) return Number(text);
++ fail(line, `a value this reader does not know: ${text}`);
++}
++
++/** Split an array body on commas outside strings. */
++function splitArray(body, line) {
++ const out = [];
++ let quote = null;
++ let start = 0;
++ for (let i = 0; i < body.length; i++) {
++ const c = body[i];
++ if (quote) {
++ if (c === '\\' && quote === '"') i += 1;
++ else if (c === quote) quote = null;
++ } else if (c === '"' || c === "'") {
++ quote = c;
++ } else if (c === ',') {
++ out.push(body.slice(start, i));
++ start = i + 1;
++ }
++ }
++ out.push(body.slice(start));
++ return out.map((s) => s.trim()).filter(Boolean);
++}
++
++function parse(source) {
++ const root = {};
++ let table = root;
++ String(source).split(/\r?\n/).forEach((raw, index) => {
++ const line = index + 1;
++ const text = stripComment(raw).trim();
++ if (!text) return;
++ if (text.startsWith('[')) {
++ if (!text.endsWith(']')) fail(line, 'an unterminated table name');
++ const path = text.slice(1, -1).trim().split('.').map((s) => s.trim()).filter(Boolean);
++ if (!path.length) fail(line, 'an empty table name');
++ table = root;
++ for (const key of path) {
++ if (!table[key] || typeof table[key] !== 'object' || Array.isArray(table[key])) table[key] = {};
++ table = table[key];
++ }
++ return;
++ }
++ const eq = text.indexOf('=');
++ if (eq < 1) fail(line, 'expected key = value');
++ const key = text.slice(0, eq).trim();
++ if (!/^[A-Za-z0-9_-]+$/.test(key)) fail(line, `an invalid key: ${key}`);
++ table[key] = parseValue(text.slice(eq + 1), line);
++ });
++ return root;
++}
++
++module.exports = { parse };
ddiff --git a/deploy/systemd/openvibe-mail.service b/deploy/systemd/openvibe-mail.service
nnew file mode 100644
iindex 0000000..0fdd685
---- /dev/null
++++ b/deploy/systemd/openvibe-mail.service
@@@ -0,0 +1,35 @@
++# OpenVibe.Mail — the Node service (API on 127.0.0.1:$PORT).
++#
++# It is a separate unit from Stalwart: the API can restart, migrate and roll back without touching the receiving
++# mail server, and Stalwart keeps its own memory cap. The API is not public: nginx/Network reaches it on loopback.
++[Unit]
++Description=OpenVibe.Mail API (masks, burners, agent inboxes)
++Documentation=https://github.com/OpenVibers/OpenVibe.Mail
++After=network-online.target openvibe-stalwart.service
++Wants=network-online.target
++
++[Service]
++Type=simple
++User=openvibe
++Group=openvibe
++EnvironmentFile=/etc/openvibe/mail.env
++WorkingDirectory=/opt/openvibe.mail
++ExecStart=/usr/bin/node server/index.js
++Restart=on-failure
++RestartSec=5
++TimeoutStopSec=20
++
++NoNewPrivileges=true
++ProtectSystem=strict
++ProtectHome=true
++PrivateTmp=true
++PrivateDevices=true
++StateDirectory=openvibe-mail
++ReadWritePaths=/var/lib/openvibe-mail
++RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
++LockPersonality=true
++SystemCallArchitectures=native
++UMask=0077
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/deploy/systemd/openvibe-stalwart.service b/deploy/systemd/openvibe-stalwart.service
nnew file mode 100644
iindex 0000000..60c76a0
---- /dev/null
++++ b/deploy/systemd/openvibe-stalwart.service
@@@ -0,0 +1,48 @@
++# OpenVibe.Mail — Stalwart Community, M1: receive-only on port 25.
++#
++# Install:
++# /opt/openvibe.stalwart/stalwart the pinned binary (deploy/stalwart/VERSION, sha256 verified)
++# /etc/openvibe/stalwart/config.json rendered from deploy/stalwart/config.toml
++# /etc/openvibe/stalwart.env STALWART_DB_PASSWORD and STALWART_RECOVERY_ADMIN (root:root 0600)
++# deploy/systemd/openvibe-stalwart.slice this service's own memory-capped slice
++#
++# The unit runs read-only: ProtectSystem=strict, no home, a private tmp, and only the state directory writable.
++# The only capability it keeps is the one port 25 needs.
++[Unit]
++Description=OpenVibe.Mail Stalwart (receive-only SMTP; JMAP/IMAP on loopback)
++Documentation=https://github.com/OpenVibers/OpenVibe.Mail
++After=network-online.target postgresql.service
++Wants=network-online.target
++Slice=openvibe-stalwart.slice
++
++[Service]
++Type=simple
++User=openvibe
++Group=openvibe
++EnvironmentFile=/etc/openvibe/stalwart.env
++ExecStart=/opt/openvibe.stalwart/stalwart --config /etc/openvibe/stalwart/config.json
++Restart=on-failure
++RestartSec=5
++TimeoutStopSec=30
++
++# The cap is on the slice; repeated here so a unit copied out of the slice is still capped.
++MemoryMax=1G
++CPUQuota=100%
++
++NoNewPrivileges=true
++ProtectSystem=strict
++ProtectHome=true
++PrivateTmp=true
++PrivateDevices=true
++StateDirectory=openvibe-stalwart
++ReadWritePaths=/var/lib/openvibe-stalwart
++CapabilityBoundingSet=CAP_NET_BIND_SERVICE
++AmbientCapabilities=CAP_NET_BIND_SERVICE
++RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
++LockPersonality=true
++SystemCallArchitectures=native
++UMask=0077
++LimitNOFILE=65536
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/deploy/systemd/openvibe-stalwart.slice b/deploy/systemd/openvibe-stalwart.slice
nnew file mode 100644
iindex 0000000..76e20bf
---- /dev/null
++++ b/deploy/systemd/openvibe-stalwart.slice
@@@ -0,0 +1,14 @@
++# OpenVibe.Mail — the mail server's own slice.
++#
++# The host OOMed on 2026-11-07. Stalwart gets its own cgroup so a mail storm, a spam-filter model or a bad
++# configuration cannot take the rest of the host with it. MemoryMax is the hard ceiling; MemoryHigh starts
++# reclaiming before it.
++[Unit]
++Description=OpenVibe.Mail Stalwart slice (memory-capped after the host OOM on 2026-11-07)
++Documentation=https://github.com/OpenVibers/OpenVibe.Mail
++
++[Slice]
++MemoryMax=1G
++MemoryHigh=800M
++CPUQuota=100%
++TasksMax=512
ddiff --git a/docs/DNS.md b/docs/DNS.md
nnew file mode 100644
iindex 0000000..d2d5123
---- /dev/null
++++ b/docs/DNS.md
@@@ -0,0 +1,97 @@
++# DNS for M1 (receive only)
++
++M1 receives for `openvibe.email`, `mask.openvibe.email` and each burner subdomain in `BURNER_DOMAINS`. It does not
++send: until M2 there is no DKIM key and no sending path, so SPF and DMARC are strict and closed.
++
++Replace `203.0.113.10` with the host's public IPv4 (and `2001:db8::10` with its IPv6, if the host has one). The MX
++target is the same host as the web server for M1; M3's sending reputation wants a separate mail IPv4, not a new
++name.
++
++## Records
++
++### `openvibe.email` (the mailboxes and agent inboxes)
++
++| Type | Name | Value | TTL |
++|---|---|---|---|
++| `MX` | `openvibe.email.` | `10 mail.openvibe.email.` | 300 |
++| `A` | `mail.openvibe.email.` | `203.0.113.10` | 300 |
++| `AAAA` | `mail.openvibe.email.` | `2001:db8::10` (only if the host has IPv6) | 300 |
++| `TXT` | `openvibe.email.` | `v=spf1 -all` | 300 |
++| `TXT` | `_dmarc.openvibe.email.` | `v=DMARC1; p=reject; adkim=s; aspf=s; pct=100; rua=mailto:[email protected]` | 300 |
++
++`v=spf1 -all` says "no host is allowed to send as this domain". That is correct while nothing sends. When M3 starts
++sending, replace it with the sending hosts and keep `-all`; do not weaken DMARC to `p=none` for convenience.
++
++`rua` needs a mailbox that exists. Provision `[email protected]` before publishing, or point `rua` at an
++address the owner already reads. A report address that bounces loses the reports.
++
++### `mask.openvibe.email` (site masks)
++
++| Type | Name | Value | TTL |
++|---|---|---|---|
++| `MX` | `mask.openvibe.email.` | `10 mail.openvibe.email.` | 300 |
++| `TXT` | `mask.openvibe.email.` | `v=spf1 -all` | 300 |
++| `TXT` | `_dmarc.mask.openvibe.email.` | `v=DMARC1; p=reject; adkim=s; aspf=s; pct=100; rua=mailto:[email protected]` | 300 |
++
++### Each burner subdomain in `BURNER_DOMAINS`
++
++For every name in `BURNER_DOMAINS` (e.g. `burn1.openvibe.email`, `burn2.openvibe.email`,
++`burn3.openvibe.email`):
++
++| Type | Name | Value | TTL |
++|---|---|---|---|
++| `MX` | `burn1.openvibe.email.` | `10 mail.openvibe.email.` | 300 |
++| `TXT` | `burn1.openvibe.email.` | `v=spf1 -all` | 300 |
++| `TXT` | `_dmarc.burn1.openvibe.email.` | `v=DMARC1; p=reject; adkim=s; aspf=s; pct=100; rua=mailto:[email protected]` | 300 |
++
++A new burner domain is a DNS record and an entry in `BURNER_DOMAINS`; there is no code change and no service
++restart needed for the address generator to use it, though the service adds the domain to Stalwart at boot.
++
++## Things that break receiving
++
++- **Cloudflare proxy (orange cloud) on the MX target.** Cloudflare does not proxy SMTP. The `mail` A/AAAA record
++ must be **DNS only** (grey cloud). Do the same for any CNAME that ends at the mail host.
++- **A second MX with a different priority.** M1 has one host; a second MX that does not accept mail causes
++ retries and lost mail.
++- **Publishing MX before the A record resolves.** Wait for the A record, then the MX.
++- **A wildcard `*.openvibe.email` MX.** It would also catch names that are not ours to receive; list the burner
++ domains explicitly instead.
++- **Uppercase or trailing-dot mistakes.** MX targets are names, not addresses; keep the trailing dot in a zone
++ file.
++
++## Not needed for M1
++
++- **PTR / reverse DNS** matters for sending, not receiving. Add `mail.openvibe.email` as the PTR when M3 turns on
++ direct sending.
++- **DKIM** has no key because nothing signs. Stalwart generates a DKIM key per domain and shows the records in
++ `Domain.dnsZoneFile`; publish them when sending starts, not before.
++- **MTA-STS and `_smtp._tls`** are useful once the server has a valid TLS certificate and clients fetch policy
++ over HTTPS. M1's SMTP listener can offer STARTTLS with the host certificate; add the records when that
++ certificate is in place. Do not publish an MTA-STS policy that promises a mode the host cannot serve.
++- **Autodiscover/autoconfig** for the public: JMAP and IMAP are loopback-only in M1, so there is nothing for a
++ mail client to discover yet.
++
++## Check
++
++```sh
++dig +short MX openvibe.email
++dig +short MX mask.openvibe.email
++dig +short TXT openvibe.email
++dig +short TXT _dmarc.openvibe.email
++dig +short A mail.openvibe.email
++```
++
++Then, from outside the host:
++
++```sh
++python3 - <<'PY'
++import smtplib
++s = smtplib.SMTP('mail.openvibe.email', 25, timeout=10)
++print(s.ehlo())
++print(s.noop())
++s.quit()
++PY
++```
++
++A `250` greeting with `mail.openvibe.email` is the receive path working. Do not use a burner or mask address you do
++not own for the check.
ddiff --git a/docs/INSTALL.md b/docs/INSTALL.md
nnew file mode 100644
iindex 0000000..ff75266
---- /dev/null
++++ b/docs/INSTALL.md
@@@ -0,0 +1,250 @@
++# Installing M1 on the host
++
++M1 is receive-only. Do not install it on the production host until Opus has reviewed the PRs. These are the steps
++Opus runs; nothing in this repository deploys itself.
++
++The order matters: PostgreSQL and the blob store first, then Stalwart in recovery mode so its default listeners do
++not collide with the plan, then the plan's listeners, then the Node service.
++
++## 0. What is needed
++
++- The host's public IPv4 (and IPv6 if it has one), with inbound TCP 25 allowed and **587/465 closed**.
++- PostgreSQL 18 reachable on the host (direct for migrations; PgBouncer for the runtime if the host uses it).
++- A Backblaze B2 bucket `openvibe-mail` and an application key limited to that bucket (read/write).
++- Node 22 on the host.
++- The pinned Stalwart binary, see [`deploy/stalwart/VERSION`](../deploy/stalwart/VERSION).
++
++Create the service user once:
++
++```sh
++sudo useradd --system --home /var/lib/openvibe-stalwart --shell /usr/sbin/nologin openvibe
++sudo install -d -m 0750 -o root -g openvibe /etc/openvibe
++sudo install -d -m 0755 -o root -g root /opt/openvibe.stalwart /opt/openvibe.mail
++```
++
++## 1. Database
++
++The house way is `OpenVibe.Host roles/data/add-service.sh`; if that is not used, the equivalent is:
++
++```sh
++sudo -u postgres psql <<'SQL'
++CREATE ROLE ov_mail LOGIN PASSWORD 'REPLACE_WITH_A_LONG_PASSWORD';
++CREATE DATABASE ov_mail OWNER ov_mail;
++SQL
++```
++
++`ov_mail` is both the service's database and Stalwart's data store. Stalwart creates its own tables in `public`;
++the service's migrations create `mail_*` and the directory view. They share the database on purpose.
++
++### The directory shape (M1)
++
++`mail_directory_accounts` is the only thing Stalwart reads to decide who an address is:
++
++- **Every burner is its own account.** The row's `name` is the burner address itself, anonymous or owned. One
++ burner's mail can never appear in another burner's mailbox, and the service reads that account with no recipient
++ filtering at all. The purge job deletes the messages, destroys the account and removes the row together.
++- **Masks and agent inboxes are aliases of the person's mailbox.** Their row's `name` is the owner's mailbox
++ address and the address is listed by `queryEmailAliases`. The service matches each message against the **first**
++ `Delivered-To` header — the one Stalwart stamps at delivery — never against a sender-written To, Cc or
++ Delivered-To.
++- There is **no pool mailbox**; a burner's account is the isolation boundary. `mail_settings` holds the purge
++ lease and the row that serializes the anonymous-burner cap.
++- A disabled mask, an expired burner and a burner that took its one message resolve to no row, so the mail server
++ refuses the address.
++
++## 2. Blob store
++
++Create the bucket and an application key in B2. Note the endpoint (for example
++`https://s3.eu-central-003.backblazeb2.com`) and the region (`eu-central-003`). Stalwart stores message blobs
++there; the service never reads blobs directly.
++
++## 3. The Stalwart binary
++
++On the host, from a checkout of this repository:
++
++```sh
++node scripts/fetch-stalwart.js
++sha256sum .cache/stalwart/stalwart-x86_64-unknown-linux-gnu.tar.gz # must equal deploy/stalwart/VERSION
++sudo install -m 0755 .cache/stalwart/stalwart /opt/openvibe.stalwart/stalwart
++/opt/openvibe.stalwart/stalwart --version # 1.0.3
++```
++
++Do not substitute a newer release without reviewing `UPGRADING/` and re-running the acceptance test: the
++configuration is written against the pinned JMAP object schema.
++
++## 4. Environment files
++
++`/etc/openvibe/stalwart.env` (root:root, 0600):
++
++```sh
++STALWART_DB_HOST=127.0.0.1
++STALWART_DB_PORT=5432
++STALWART_DB_NAME=ov_mail
++STALWART_DB_USER=ov_mail
++STALWART_DB_PASSWORD=REPLACE
++STALWART_BLOB_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
++STALWART_BLOB_BUCKET=openvibe-mail
++STALWART_BLOB_REGION=eu-central-003
++STALWART_BLOB_ACCESS_KEY_ID=REPLACE
++STALWART_BLOB_SECRET_KEY=REPLACE
++```
++
++`/etc/openvibe/mail.env` (root:root, 0600) — names are described in `.env.example`; the values that matter for M1:
++
++```sh
++NODE_ENV=production
++PORT=5250
++HOST=127.0.0.1
++BASE_URL=https://openvibe.email
++TRUST_PROXY=2
++DATABASE_URL=postgres://ov_mail:[email protected]:5432/ov_mail
++DATABASE_DIRECT_URL=postgres://ov_mail:[email protected]:5432/ov_mail
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++MAIL_BASE_DOMAIN=openvibe.email
++MAIL_MASK_DOMAIN=mask.openvibe.email
++BURNER_DOMAINS=burn1.openvibe.email,burn2.openvibe.email,burn3.openvibe.email
++MAIL_MAX_LIVE_ANON_BURNERS=2000
++STALWART_URL=http://127.0.0.1:8080
++STALWART_ADMIN_USER=admin
++STALWART_ADMIN_PASSWORD=REPLACE_WITH_THE_RECOVERY_ADMIN_PASSWORD
++MAIL_EVENTS_URL=http://127.0.0.1:4300
++MAIL_EVENTS_CLIENT_ID=mail
++MAIL_EVENTS_CLIENT_SECRET=REPLACE
++```
++
++If the host uses PgBouncer, `DATABASE_URL` points at it and `DATABASE_DIRECT_URL` stays direct; migrations run on
++the direct connection. The Stalwart env file needs the blob keys only for `apply.js`; the running server reads the
++blob store from its database.
++
++## 5. Render the configuration
++
++```sh
++sudo sh -c 'set -a; . /etc/openvibe/stalwart.env; set +a; \
++ STALWART_RENDER_DIR=/etc/openvibe/stalwart node deploy/stalwart/render.js'
++sudo chown root:openvibe /etc/openvibe/stalwart/config.json /etc/openvibe/stalwart/plan.json
++sudo chmod 0640 /etc/openvibe/stalwart/config.json
++sudo chmod 0600 /etc/openvibe/stalwart/plan.json
++```
++
++`config.json` is the small data-store file Stalwart v1.0.3 reads; its password is an environment variable
++reference, not a value. `plan.json` is the JMAP reconciliation plan and contains no secret.
++
++## 6. First start in recovery mode, then apply
++
++Recovery mode binds one HTTP port and none of the default mail listeners, so the plan can remove the submission,
++POP3, Sieve and HTTPS listeners before they ever open.
++
++Use the same password after `admin:` here and as `STALWART_ADMIN_PASSWORD` in `mail.env`: the Node service reads mail with it.
++
++Create a one-time recovery env file (root:openvibe, 0640) so the admin password is never on a command line:
++
++```sh
++sudo sh -c 'umask 027; cat > /etc/openvibe/stalwart-recovery.env <<EOF
++STALWART_DB_PASSWORD=$(. /etc/openvibe/stalwart.env; printf %s "$STALWART_DB_PASSWORD")
++STALWART_RECOVERY_ADMIN=admin:REPLACE_WITH_A_LONG_ADMIN_PASSWORD
++STALWART_RECOVERY_MODE=1
++STALWART_RECOVERY_MODE_PORT=18080
++EOF'
++sudo chown root:openvibe /etc/openvibe/stalwart-recovery.env
++sudo chmod 0640 /etc/openvibe/stalwart-recovery.env
++sudo -u openvibe sh -c 'set -a; . /etc/openvibe/stalwart-recovery.env; set +a; \
++ exec /opt/openvibe.stalwart/stalwart --config /etc/openvibe/stalwart/config.json' &
++echo $! > /tmp/stalwart-recovery.pid
++```
++
++Wait until `ss -ltn | grep 18080` shows the recovery listener, then apply:
++
++```sh
++sudo sh -c 'set -a; . /etc/openvibe/stalwart.env; . /etc/openvibe/mail.env; set +a; \
++ STALWART_URL=http://127.0.0.1:18080 node deploy/stalwart/apply.js --plan /etc/openvibe/stalwart/plan.json'
++kill "$(cat /tmp/stalwart-recovery.pid)"
++rm -f /etc/openvibe/stalwart-recovery.env
++```
++
++The first pass creates the SQL directory, the domains, the S3 blob store, the spam setting and the three listeners.
++It may skip the default roles because recovery mode has not written them yet; the second pass below sets them.
++
++## 7. Start the service and apply the roles
++
++Install the units:
++
++```sh
++sudo install -m 0644 deploy/systemd/openvibe-stalwart.slice /etc/systemd/system/
++sudo install -m 0644 deploy/systemd/openvibe-stalwart.service /etc/systemd/system/
++sudo install -m 0644 deploy/systemd/openvibe-mail.service /etc/systemd/system/
++sudo systemctl daemon-reload
++sudo systemctl enable --now openvibe-stalwart.slice openvibe-stalwart.service
++```
++
++Now the normal start has written the built-in roles. Apply again; this sets `Authentication.defaultUserRoleIds`
++(the `User` role) and reloads the settings, so an account the directory creates receives mail instead of being
++refused with "This account is not authorized to receive email":
++
++```sh
++sudo sh -c 'set -a; . /etc/openvibe/stalwart.env; . /etc/openvibe/mail.env; set +a; \
++ STALWART_URL=http://127.0.0.1:8080 node deploy/stalwart/apply.js --plan /etc/openvibe/stalwart/plan.json'
++```
++
++Check what is listening:
++
++```sh
++sudo ss -ltnp | grep -E ':(25|143|8080|5250)\b'
++```
++
++Expected: `0.0.0.0:25` (or the host's public address), `127.0.0.1:8080`, `127.0.0.1:143`. There must be **no**
++`587`, `465`, `993`, `995`, `4190` or `:443` listener from Stalwart.
++
++## 8. The Node service
++
++```sh
++sudo rsync -a --delete --exclude node_modules --exclude .git --exclude .cache ./ /opt/openvibe.mail/
++cd /opt/openvibe.mail
++sudo npm ci --omit=dev --no-audit --no-fund
++sudo sh -c 'set -a; . /etc/openvibe/mail.env; set +a; node deploy/init-db.js'
++sudo systemctl enable --now openvibe-mail.service
++```
++
++`deploy/init-db.js` runs the migrations through `DATABASE_DIRECT_URL`; the service would also do it at boot, but a
++failure here is easier to read. The service adds the domains it knows on start; there is no pool mailbox.
++
++## 9. DNS
++
++Publish the records in [`docs/DNS.md`](DNS.md), keeping the mail A/AAAA records **DNS-only** in Cloudflare. Wait for
++the MX, then test from outside:
++
++```sh
++dig +short MX openvibe.email
++python3 -c "import smtplib; s=smtplib.SMTP('mail.openvibe.email',25,timeout=10); print(s.ehlo()[1]); s.quit()"
++```
++
++## 10. Verify
++
++```sh
++curl -fsS http://127.0.0.1:5250/api/health
++curl -fsS http://127.0.0.1:5250/api/ready
++systemctl status openvibe-stalwart.service openvibe-mail.service --no-pager
++journalctl -u openvibe-stalwart -u openvibe-mail --since '10 min ago' --no-pager | tail -40
++```
++
++Then, with a person's token, create a mask, send it one message from another account, and read it through JMAP (or
++create a burner and use `GET /api/v1/burners/:id/code`). A body must never appear in either journal.
++
++## 11. Firewall and things not to do
++
++- Allow inbound TCP 25 only. Do not open 587 or 465: there is no submission listener and no relay.
++- Keep 8080, 143 and 5250 on loopback. The public API is whatever nginx/Network fronts; M1 has no public site.
++- Do not add a second MX or an outbound SMTP path. Sending is M3.
++- Do not run Stalwart as root; the unit keeps only `CAP_NET_BIND_SERVICE` for port 25.
++- The memory cap is the slice's (`/etc/systemd/system/openvibe-stalwart.slice`). The host OOMed on 2026-11-07; do
++ not raise `MemoryMax` without measuring the spam filter and a busy mailbox first.
++
++## 12. Rollback
++
++```sh
++sudo systemctl stop openvibe-mail.service openvibe-stalwart.service
++sudo systemctl disable openvibe-mail.service openvibe-stalwart.service
++```
++
++The data stays in PostgreSQL (`ov_mail`) and B2. Removing the DNS MX records stops new mail; mail already queued on
++other servers retries until their expiry, so keep the host reachable until the queues drain.
ddiff --git a/migrations/0001_mail_addresses.sql b/migrations/0001_mail_addresses.sql
nnew file mode 100644
iindex 0000000..ac55880
---- /dev/null
++++ b/migrations/0001_mail_addresses.sql
@@@ -0,0 +1,57 @@
++-- phase: expand
++-- OpenVibe.Mail — the addresses we own.
++--
++-- A mask is a per-site address that delivers into OpenVibe and nowhere else; a burner is a throwaway address with
++-- an expiry; an agent's inbox is an address an agent can read and answer from. Each of them belongs to a person
++-- (owner_subject, the Network subject id) except a ten-minute burner, which may be created without an account.
++--
++-- The mailbox table says where a person's mail lands: one Stalwart account per person, addressed
++-- <local>@<MAIL_BASE_DOMAIN>. Masks and agent inboxes are aliases of that account. A burner is different: it is
++-- its own directory account, so no message can cross from one burner to another through a shared mailbox. The
++-- directory view (0002) turns the three tables below into the rows Stalwart's SQL directory reads.
++
++CREATE TABLE IF NOT EXISTS mail_mailboxes (
++ owner_subject text PRIMARY KEY,
++ address text NOT NULL UNIQUE,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE TABLE IF NOT EXISTS mail_masks (
++ id text PRIMARY KEY,
++ owner_subject text NOT NULL REFERENCES mail_mailboxes (owner_subject) ON DELETE CASCADE,
++ address text NOT NULL UNIQUE,
++ label text,
++ purpose text,
++ enabled boolean NOT NULL DEFAULT true,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE INDEX IF NOT EXISTS mail_masks_owner_idx ON mail_masks (owner_subject, created_at DESC);
++
++CREATE TABLE IF NOT EXISTS mail_burners (
++ id text PRIMARY KEY,
++ owner_subject text REFERENCES mail_mailboxes (owner_subject) ON DELETE CASCADE,
++ address text NOT NULL UNIQUE,
++ token_hash text,
++ guest_id text,
++ expires_at timestamptz NOT NULL,
++ first_message_only boolean NOT NULL DEFAULT false,
++ first_message_at timestamptz,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE INDEX IF NOT EXISTS mail_burners_owner_idx ON mail_burners (owner_subject, created_at DESC);
++CREATE INDEX IF NOT EXISTS mail_burners_expiry_idx ON mail_burners (expires_at);
++
++CREATE TABLE IF NOT EXISTS mail_agent_inboxes (
++ id text PRIMARY KEY,
++ owner_subject text NOT NULL REFERENCES mail_mailboxes (owner_subject) ON DELETE CASCADE,
++ agent_id text NOT NULL,
++ address text NOT NULL UNIQUE,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE INDEX IF NOT EXISTS mail_agent_inboxes_owner_idx ON mail_agent_inboxes (owner_subject, created_at DESC);
++CREATE INDEX IF NOT EXISTS mail_agent_inboxes_agent_idx ON mail_agent_inboxes (agent_id);
++-- One agent has one inbox: asking twice gives the same address.
++CREATE UNIQUE INDEX IF NOT EXISTS mail_agent_inboxes_owner_agent_idx ON mail_agent_inboxes (owner_subject, agent_id);
ddiff --git a/migrations/0002_directory_views.sql b/migrations/0002_directory_views.sql
nnew file mode 100644
iindex 0000000..bb61777
---- /dev/null
++++ b/migrations/0002_directory_views.sql
@@@ -0,0 +1,56 @@
++-- phase: expand
++-- OpenVibe.Mail — the rows Stalwart's SQL directory reads.
++--
++-- Stalwart asks the directory "who owns this address?" for every inbound recipient. One row means one address
++-- resolves to one person's mailbox; no row means the address does not exist and the mail bounces. The rule is
++-- simple and is the whole privacy model of M1:
++--
++-- * the mailbox itself resolves to itself, so the account Stalwart creates for an alias has a primary
++-- address the local delivery can find (and mail to <user>@openvibe.email is not a relay attempt);
++-- * a mask that is on resolves to its owner's mailbox;
++-- * a burner that has not expired resolves to **its own** mailbox: the burner address is its own Stalwart
++-- directory account, so its mail never shares an account with another burner (anonymous or owned);
++-- * an agent's inbox resolves to its owner's mailbox;
++-- * a mask that is off, a burner that has expired, and a burner that took its one message resolve to nothing —
++-- so the address bounces.
++--
++-- `name` is the primary address of the Stalwart account the row resolves to. For a person it is their mailbox
++-- address, and masks/agent inboxes are aliases of it; for a burner it is the burner address itself. `secret` is
++-- empty on purpose: the accounts have no password, so they can receive but never log in.
++--
++-- The view reads the moment, not the row: `now()` is evaluated per query, so a burner stops resolving the second
++-- it expires even if the purge job has not run yet.
++
++CREATE OR REPLACE VIEW mail_directory_accounts AS
++ SELECT mb.address AS address,
++ mb.address AS name,
++ mb.owner_subject AS owner,
++ ''::text AS secret,
++ 'mailbox'::text AS description
++ FROM mail_mailboxes mb
++ UNION ALL
++ SELECT m.address AS address,
++ mb.address AS name,
++ m.owner_subject AS owner,
++ ''::text AS secret,
++ 'mask'::text AS description
++ FROM mail_masks m
++ JOIN mail_mailboxes mb ON mb.owner_subject = m.owner_subject
++ WHERE m.enabled
++ UNION ALL
++ SELECT b.address AS address,
++ b.address AS name,
++ b.owner_subject AS owner,
++ ''::text AS secret,
++ 'burner'::text AS description
++ FROM mail_burners b
++ WHERE b.expires_at > now()
++ AND (NOT b.first_message_only OR b.first_message_at IS NULL)
++ UNION ALL
++ SELECT a.address AS address,
++ mb.address AS name,
++ a.owner_subject AS owner,
++ ''::text AS secret,
++ 'agent inbox'::text AS description
++ FROM mail_agent_inboxes a
++ JOIN mail_mailboxes mb ON mb.owner_subject = a.owner_subject;
ddiff --git a/migrations/0003_settings_and_outbox.sql b/migrations/0003_settings_and_outbox.sql
nnew file mode 100644
iindex 0000000..87b35bb
---- /dev/null
++++ b/migrations/0003_settings_and_outbox.sql
@@@ -0,0 +1,34 @@
++-- phase: expand
++-- OpenVibe.Mail — the service's own settings, and the events outbox.
++--
++-- The settings table is small service state. It holds the purge job's lease and the row that serializes the
++-- anonymous-burner cap; the old burner-pool mailbox setting is gone because every burner is its own Stalwart
++-- directory account and the purge job reads that account directly.
++
++CREATE TABLE IF NOT EXISTS mail_settings (
++ key text PRIMARY KEY,
++ value text NOT NULL,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++
++-- The events outbox (ADR-004): mail.received and mail.mask.created are written in the same transaction as the change
++-- that caused them, and the relay publishes them to OpenVibe.Events.
++--
++-- The shape is the SDK's (openvibe-sdk/outbox): the times are epoch milliseconds, and a row that was refused for
++-- good is kept with rejected_at set, so a bad envelope is looked at rather than sent again and again.
++CREATE TABLE IF NOT EXISTS event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++
++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
ddiff --git a/migrations/0004_rate_limits.sql b/migrations/0004_rate_limits.sql
nnew file mode 100644
iindex 0000000..41747e8
---- /dev/null
++++ b/migrations/0004_rate_limits.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- OpenVibe.Mail — the counters behind the abuse limits.
++--
++-- A burner made without an account is the one thing anybody on the internet can ask for, so it is limited twice:
++-- by the address it came from (ten an hour) and by how many are alive at once (five). The counters live here, not in
++-- memory, so a restart does not hand everybody a fresh allowance.
++--
++-- One row per (key, window). The key says what is being counted — 'burner:ip:<address>' — and the window says
++-- when it started. When the window is over the row is replaced and the count starts again.
++
++CREATE TABLE IF NOT EXISTS mail_rate_limits (
++ key text PRIMARY KEY,
++ window_started_at timestamptz NOT NULL,
++ count integer NOT NULL DEFAULT 0,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE INDEX IF NOT EXISTS mail_rate_limits_window_idx ON mail_rate_limits (window_started_at);
ddiff --git a/migrations/0005_observed_messages.sql b/migrations/0005_observed_messages.sql
nnew file mode 100644
iindex 0000000..a67d416
---- /dev/null
++++ b/migrations/0005_observed_messages.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- OpenVibe.Mail — the messages the service has already seen.
++--
++-- The service reads a person's mailbox when somebody asks for a burner's messages or its code. Every message it
++-- sees is written here, once per address, and the first time it is seen the service publishes mail.received. The
++-- row is the answer to "have I already told the world about this message?" — without it the same letter would be
++-- announced again on every read.
++--
++-- Only the address, the message id and the time are kept. The body is never written down.
++
++CREATE TABLE IF NOT EXISTS mail_observed_messages (
++ address text NOT NULL,
++ message_id text NOT NULL,
++ received_at timestamptz,
++ first_seen_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (address, message_id)
++);
++
++CREATE INDEX IF NOT EXISTS mail_observed_messages_seen_idx ON mail_observed_messages (first_seen_at);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..640bd1e
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1575 @@
++{
++ "name": "openvibe-mail",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-mail",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.15",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz",
++ "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.9",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/cssomnom": {
++ "version": "0.1.11",
++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.9",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/linkedom": {
++ "version": "0.18.15",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
++ "license": "ISC",
++ "dependencies": {
++ "css-select": "^7.0.0",
++ "cssomnom": "^0.1.11",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.2",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.327.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
++ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.97.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
++ "integrity": "sha512-zCRmu7NoOi7kI0v04N8uypCgsuEcf9kCzkw88r0+dj9PG3AA/z+Pp4BZaX6Z1JUE3sdrtc17D5LcD05IbWsw8w==",
++ "license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.135.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
++ "integrity": "sha512-+v59lQjCjkb8poKNyKOKfTr8SoFMyr/4dKMoIpPgxqvn8MDFYpylvI80U3AvSxeJTqZpthhrBBdkgVlok/M3dg==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
++ },
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/pg": {
++ "version": "8.23.1",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.1",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.1",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.1"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.1",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.6",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..f14f0ce
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,41 @@
++{
++ "name": "openvibe-mail",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Mail — openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "init-db": "node deploy/init-db.js",
++ "test": "node test/run.js",
++ "test:pg": "MAIL_TEST_STORE=pg node test/run.js",
++ "test:stalwart": "node test/stalwart/run.js",
++ "stalwart:fetch": "node scripts/fetch-stalwart.js",
++ "stalwart:render": "node deploy/stalwart/render.js",
++ "stalwart:apply": "node deploy/stalwart/apply.js"
++ },
++ "keywords": [
++ "openvibe",
++ "mail",
++ "masks",
++ "burners"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/scripts/fetch-stalwart.js b/scripts/fetch-stalwart.js
nnew file mode 100644
iindex 0000000..23c6a91
---- /dev/null
++++ b/scripts/fetch-stalwart.js
@@@ -0,0 +1,84 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Download the pinned Stalwart Community binary and verify the sha256 in deploy/stalwart/VERSION.
++ *
++ * node scripts/fetch-stalwart.js [--force]
++ *
++ * CI runs this before `npm run test:stalwart`; a workstation can run it once and then keep it in .cache/stalwart.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const https = require('https');
++const path = require('path');
++const { execFileSync } = require('child_process');
++
++const ROOT = path.join(__dirname, '..');
++const CACHE = path.join(ROOT, '.cache', 'stalwart');
++
++function readVersion() {
++ const text = fs.readFileSync(path.join(ROOT, 'deploy', 'stalwart', 'VERSION'), 'utf8');
++ const out = {};
++ for (const line of text.split(/\r?\n/)) {
++ const m = /^([a-z0-9_]+)=(.*)$/.exec(line.trim());
++ if (m) out[m[1]] = m[2];
++ }
++ for (const key of ['version', 'asset', 'sha256', 'url']) {
++ if (!out[key]) throw new Error(`deploy/stalwart/VERSION has no ${key}`);
++ }
++ if (!/^[0-9a-f]{64}$/.test(out.sha256)) throw new Error('deploy/stalwart/VERSION sha256 is not 64 hex characters');
++ return out;
++}
++
++function download(url, to, redirects = 0) {
++ return new Promise((resolve, reject) => {
++ https.get(url, { headers: { 'user-agent': 'openvibe-mail-ci' } }, (res) => {
++ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
++ res.resume();
++ if (redirects > 5) return reject(new Error('too many redirects'));
++ return download(res.headers.location, to, redirects + 1).then(resolve, reject);
++ }
++ if (res.statusCode !== 200) {
++ res.resume();
++ return reject(new Error(`download failed: HTTP ${res.statusCode} ${url}`));
++ }
++ const file = fs.createWriteStream(to, { mode: 0o600 });
++ res.pipe(file);
++ file.on('finish', () => file.close(resolve));
++ file.on('error', reject);
++ }).on('error', reject);
++ });
++}
++
++function sha256(file) {
++ const hash = crypto.createHash('sha256');
++ hash.update(fs.readFileSync(file));
++ return hash.digest('hex');
++}
++
++async function main() {
++ const version = readVersion();
++ const binary = path.join(CACHE, 'stalwart');
++ const archive = path.join(CACHE, version.asset);
++ if (process.argv.includes('--force') === false && fs.existsSync(binary)) {
++ process.stdout.write(`Stalwart ${version.version} already at ${path.relative(ROOT, binary)}\n`);
++ return;
++ }
++ fs.mkdirSync(CACHE, { recursive: true });
++ await download(version.url, archive);
++ const actual = sha256(archive);
++ if (actual !== version.sha256) {
++ fs.rmSync(archive, { force: true });
++ throw new Error(`sha256 mismatch: expected ${version.sha256}, got ${actual}`);
++ }
++ execFileSync('tar', ['-xzf', archive, '-C', CACHE]);
++ fs.chmodSync(binary, 0o755);
++ process.stdout.write(`Stalwart ${version.version} verified (${version.sha256}) at ${path.relative(ROOT, binary)}\n`);
++}
++
++if (require.main === module) {
++ main().catch((err) => { process.stderr.write(`fetch-stalwart: ${err.message}\n`); process.exit(1); });
++}
++
++module.exports = { readVersion, sha256 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..79c8bb9
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,95 @@
++'use strict';
++
++/**
++ * OpenVibe.Mail — the Express app. server/index.js listens; the tests build their own instance with a temporary
++ * database and a stand-in for the mail server.
++ *
++ * /api/health, /api/ready the machine answers
++ * /api/v1/masks, /burners, … the API (http/api.js)
++ *
++ * There are no pages: M1 has no site, only an API for the apps and the agents of the house.
++ *
++ * Nothing here logs a request body. A request body of this service may carry a person's label for a mask; a
++ * message body never reaches this process at all, and the one place a body is read (the code extractor) throws it
++ * away as soon as it has read it.
++ */
++const express = require('express');
++const helmet = require('helmet');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createKeyStore } = require('./auth/keys');
++const { createPrincipal } = require('./http/principal');
++const { createApi } = require('./http/api');
++const { createServiceReadiness } = require('./observability');
++const { createStalwartClient } = require('./stalwart/client');
++const { createEvents } = require('./events');
++
++const VERSION = require('../package.json').version;
++
++/**
++ * opts: config, store, stalwart, events, now (clock), fetchImpl (Network), log.
++ */
++async function createApp(opts = {}) {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const store = opts.store || await openStore(config, { now: opts.now, log });
++
++ const keys = createKeyStore({ config, fetchImpl, log });
++ const stalwart = opts.stalwart || createStalwartClient({ config, fetchImpl, log });
++ const events = opts.events || createEvents({ db: store.db, config, now: opts.now, log });
++ const principal = createPrincipal({ config, keys });
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
++ app.use(express.json({ limit: '16kb' }));
++
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-mail', name: 'OpenVibe.Mail', version: VERSION }));
++
++ const readiness = createServiceReadiness({ db: store.db, config, stalwart, version: VERSION });
++ app.get('/api/ready', readiness.handler);
++
++ app.use('/api/v1', rateLimit({
++ windowMs: 60_000,
++ limit: config.limits.apiPerMinute,
++ standardHeaders: true,
++ legacyHeaders: false,
++ handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', {
++ detail: 'Too many requests from this address; retry shortly.',
++ ctx: req.ov,
++ }),
++ }));
++
++ app.use('/api/v1', principal.middleware);
++ app.use(createApi({ db: store.db, config, stalwart, events, principal, log }));
++
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
++ detail: 'No such API route.',
++ ctx: req.ov,
++ }));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ // The message is the error, never the request: a body of a request is not written down.
++ log.error('[OpenVibe.Mail]', err && err.message ? String(err.message).slice(0, 300) : err);
++ if (res.headersSent) return;
++ if (err && err.type === 'entity.parse.failed') {
++ return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ }
++ if (err && err.type === 'entity.too.large') {
++ return contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
++ }
++ return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error.', ctx: req.ov });
++ });
++
++ return { app, ctx: { config, store, keys, stalwart, events, principal, log } };
++}
++
++module.exports = { createApp, VERSION };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..fc19e97
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session
++ * token (sign-in) and an app's or a service's Bearer token (the API).
++ *
++ * Nothing the SDK says about a failed key fetch leaves this module: token.no_key answers "signing key not loaded
++ * yet" and nothing more.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++function publicReason(err) {
++ const code = err && err.code;
++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
++ return (err && err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
++}
++
++function jwksUrl(config) {
++ return (config.network.internalUrl || config.network.url) + '/api/.well-known/jwks';
++}
++
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null;
++
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) {
++ log.warn(`[OpenVibe.Mail] JWKS mirror refresh failed: ${err && (err.message || err)}`);
++ mirror = null;
++ }
++ }
++
++ async function ensure() { await refreshMirror(); }
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
++
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
++
++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Mail] user token rejected (${code || 'error'})`);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++
++ /** The public key of the key that signed this token, for the house's service-token check. */
++ async function pemForToken(token) {
++ let kid = null;
++ try {
++ kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null;
++ } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch {
++ return null;
++ }
++ }
++
++ return { ensure, get, loaded, status: client.status, verifyUser, pemForToken, client };
++}
++
++module.exports = { createKeyStore, jwksUrl };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..00195d6
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,124 @@
++'use strict';
++
++/**
++ * OpenVibe.Mail's configuration: names only, values from the environment.
++ *
++ * Two things are worth reading twice. The mail server (Stalwart) is reached over loopback only, and the addresses
++ * are split by domain: masks live on MAIL_MASK_DOMAIN, burners rotate over BURNER_DOMAINS (a new burner domain is
++ * a configuration change, never a code change), and an agent's inbox is <agent>.<user>@MAIL_BASE_DOMAIN.
++ */
++const path = require('path');
++
++require('dotenv').config({ path: process.env.DOTENV_PATH || path.join(__dirname, '..', '.env'), quiet: true });
++
++/** The environment: the process's own, with whatever the caller overrides (the tests pass their own). */
++let env = process.env;
++
++function int(name, fallback) {
++ const v = env[name];
++ if (v === undefined || v === '') return fallback;
++ const n = Number(v);
++ if (!Number.isInteger(n) || n < 0) throw new Error(`${name} must be a whole number`);
++ return n;
++}
++
++function list(name) {
++ return String(env[name] || '')
++ .split(',')
++ .map((s) => s.trim().toLowerCase())
++ .filter(Boolean);
++}
++
++function domain(name, fallback) {
++ const v = String(env[name] || fallback || '').trim().toLowerCase();
++ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(v)) {
++ throw new Error(`${name} must be a domain name`);
++ }
++ return v;
++}
++
++function load(overrides = null) {
++ if (overrides) env = { ...process.env, ...overrides };
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const baseDomain = domain('MAIL_BASE_DOMAIN', 'openvibe.email');
++ const maskDomain = domain('MAIL_MASK_DOMAIN', `mask.${baseDomain}`);
++ const burnerDomains = list('BURNER_DOMAINS');
++
++ if (isProduction && !env.DATABASE_URL) {
++ throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data/add-service.sh today)');
++ }
++ if (isProduction && !env.STALWART_ADMIN_PASSWORD) {
++ throw new Error('STALWART_ADMIN_PASSWORD is not set: the service reads and invalidates mail with it');
++ }
++ if (!burnerDomains.length) {
++ throw new Error('BURNER_DOMAINS is empty: a burner needs a domain to live on');
++ }
++ for (const d of burnerDomains) {
++ if (d === maskDomain || d === baseDomain) throw new Error(`BURNER_DOMAINS must not contain ${d}`);
++ }
++
++ return {
++ nodeEnv,
++ isProduction,
++ service: 'mail',
++ port: int('PORT', 5250),
++ host: env.HOST || '127.0.0.1',
++ baseUrl: env.BASE_URL || `https://${baseDomain}`,
++ trustProxy: int('TRUST_PROXY', 0),
++
++ db: {
++ url: env.DATABASE_URL || null,
++ directUrl: env.DATABASE_DIRECT_URL || null,
++ pgliteDir: env.MAIL_PGLITE_DIR || path.join(__dirname, '..', 'data', 'pglite'),
++ },
++
++ valkey: {
++ url: env.VALKEY_URL || null,
++ prefix: env.VALKEY_PREFIX || 'ov:mail:',
++ },
++
++ network: {
++ url: env.OV_NETWORK_URL || 'https://openvibe.network',
++ internalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
++ issuer: env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network',
++ audience: env.MAIL_AUDIENCE || 'openvibe.mail',
++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
++ },
++
++ addresses: {
++ base: baseDomain,
++ mask: maskDomain,
++ burners: burnerDomains,
++ },
++
++ stalwart: {
++ url: (env.STALWART_URL || 'http://127.0.0.1:8080').replace(/\/+$/, ''),
++ adminUser: env.STALWART_ADMIN_USER || 'admin',
++ adminPassword: env.STALWART_ADMIN_PASSWORD || '',
++ requestTimeoutMs: int('STALWART_TIMEOUT_MS', 20_000),
++ },
++
++ events: {
++ url: env.MAIL_EVENTS_URL || null,
++ clientId: env.MAIL_EVENTS_CLIENT_ID || 'mail',
++ clientSecret: env.MAIL_EVENTS_CLIENT_SECRET || null,
++ intervalMs: int('MAIL_EVENTS_INTERVAL_MS', 5000),
++ },
++
++ limits: {
++ maxMasks: int('MAIL_MAX_MASKS', 20),
++ maxLiveBurners: int('MAIL_MAX_LIVE_BURNERS', 5),
++ maxLiveAnonBurners: int('MAIL_MAX_LIVE_ANON_BURNERS', 2000),
++ burnersPerIpHour: int('MAIL_BURNERS_PER_IP_HOUR', 10),
++ anonBurnerTtlMinutes: int('MAIL_ANON_BURNER_TTL_MINUTES', 10),
++ apiPerMinute: int('MAIL_API_RATE_LIMIT_PER_MIN', 240),
++ },
++
++ jobs: {
++ purgeIntervalMs: int('MAIL_PURGE_INTERVAL_MS', 60_000),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..03014b9
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,50 @@
++'use strict';
++
++/**
++ * OpenVibe.Mail's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
++ *
++ * The same database holds Stalwart's own tables (it writes them with its own role and its own opaque names) and
++ * ours (mail_masks, mail_burners, mail_agent_inboxes, mail_mailboxes and the directory view Stalwart reads). The
++ * names never collide: Stalwart's tables are a single letter or a short prefix, ours are all mail_*.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data/add-service.sh today)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Mail] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'mail', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'mail-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'mail', registry, log });
++}
++
++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ tx: async (fn) => await db.tx((t) => fn(t)),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS };
ddiff --git a/server/domain/addresses.js b/server/domain/addresses.js
nnew file mode 100644
iindex 0000000..1b444ec
---- /dev/null
++++ b/server/domain/addresses.js
@@@ -0,0 +1,124 @@
++'use strict';
++
++/**
++ * The addresses we hand out.
++ *
++ * a mask <words>.<4 characters>@<mask domain> [email protected]
++ * a burner <8 characters>@<one of the burner domains> [email protected]
++ * an agent <agent>.<user>@<base domain> [email protected]
++ *
++ * The words are the person's own ("amazon" for the Amazon mask) so they can recognise the address later; the four
++ * characters after the dot keep two people from guessing each other's mask. A burner has no words at all: it is
++ * meant to be thrown away, and it rotates over the burner domains so a site that blocks one domain does not block
++ * the next.
++ *
++ * Nothing here talks to the network: an address is a string, and the database has the last word on whether it is
++ * free.
++ */
++const crypto = require('crypto');
++
++// No vowels (so a word cannot spell one) and no look-alikes: 0/o, 1/l/i.
++const ALPHABET = 'bcdfghjkmnpqrstvwxyz23456789';
++const WORDS_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
++
++function randomChars(length, alphabet = ALPHABET) {
++ const bytes = crypto.randomBytes(length * 2);
++ let out = '';
++ for (let i = 0; out.length < length && i < bytes.length; i++) {
++ const b = bytes[i];
++ // Rejection sampling: no modulo bias.
++ if (b < 256 - (256 % alphabet.length)) out += alphabet[b % alphabet.length];
++ }
++ return out.length === length ? out : out + randomChars(length - out.length, alphabet);
++}
++
++/** The words of a mask: what the person asked for, cleaned up, or a neutral default. */
++function maskWords(input) {
++ const words = String(input || '')
++ .toLowerCase()
++ .replace(/[^a-z0-9-]+/g, '-')
++ .replace(/-+/g, '-')
++ .replace(/^-|-$/g, '')
++ .slice(0, 32);
++ return WORDS_RE.test(words) ? words : 'mail';
++}
++
++/** A mask address: <words>.<4 characters>@<mask domain>. */
++function maskAddress(words, maskDomain) {
++ return `${maskWords(words)}.${randomChars(4)}@${maskDomain}`;
++}
++
++/** A burner address on the given domain. */
++function burnerAddress(domain) {
++ return `${randomChars(8)}@${domain}`;
++}
++
++/** An agent's inbox: <agent>.<user>@<base domain>. The agent id and the user's mailbox name are both cleaned up. */
++function agentInboxAddress(agentId, userLocal, baseDomain) {
++ const agent = String(agentId || '')
++ .toLowerCase()
++ .replace(/[^a-z0-9-]+/g, '-')
++ .replace(/-+/g, '-')
++ .replace(/^-|-$/g, '')
++ .slice(0, 32);
++ const user = String(userLocal || '')
++ .toLowerCase()
++ .replace(/[^a-z0-9-]+/g, '-')
++ .replace(/-+/g, '-')
++ .replace(/^-|-$/g, '')
++ .slice(0, 32);
++ if (!agent || !user) throw new Error('an agent inbox needs an agent and a user');
++ return `${agent}.${user}@${baseDomain}`;
++}
++
++/** A person's mailbox: <local>@<base domain>. */
++function mailboxAddress(local, baseDomain) {
++ const name = String(local || '')
++ .toLowerCase()
++ .replace(/[^a-z0-9-]+/g, '-')
++ .replace(/-+/g, '-')
++ .replace(/^-|-$/g, '')
++ .slice(0, 32);
++ if (!name) throw new Error('a mailbox needs a name');
++ return `${name}@${baseDomain}`;
++}
++
++/**
++ * The local part of a person's mailbox, taken from their Network subject id.
++ *
++ * A subject id is `usr_01H…`; the mailbox is named after the id without its prefix, so two people never share a
++ * mailbox and nobody has to be remembered by their name. Stalwart takes the local part as the account name and the
++ * base domain as the domain, so `usr_01H…` becomes the account `01h…` at openvibe.email.
++ */
++function mailboxLocalFor(subjectId) {
++ const raw = String(subjectId || '').trim();
++ const withoutPrefix = raw.includes('_') ? raw.slice(raw.indexOf('_') + 1) : raw;
++ const local = withoutPrefix.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32);
++ if (!local) throw new Error('a mailbox needs a subject id');
++ return local;
++}
++
++/** The domain an address lives on, lowercased, or null when it has none. */
++function domainOf(address) {
++ const at = String(address || '').lastIndexOf('@');
++ return at > 0 ? String(address).slice(at + 1).toLowerCase() : null;
++}
++
++/** The local part of an address, lowercased. */
++function localOf(address) {
++ const at = String(address || '').lastIndexOf('@');
++ return at > 0 ? String(address).slice(0, at).toLowerCase() : String(address || '').toLowerCase();
++}
++
++module.exports = {
++ ALPHABET,
++ randomChars,
++ maskWords,
++ maskAddress,
++ burnerAddress,
++ agentInboxAddress,
++ mailboxAddress,
++ mailboxLocalFor,
++ domainOf,
++ localOf,
++};
ddiff --git a/server/domain/agent-inboxes.js b/server/domain/agent-inboxes.js
nnew file mode 100644
iindex 0000000..e63d7ea
---- /dev/null
++++ b/server/domain/agent-inboxes.js
@@@ -0,0 +1,133 @@
++'use strict';
++
++/**
++ * An agent's inbox: an address an agent can read, on the person's own domain.
++ *
++ * The address is <agent>.<user>@<base domain> — [email protected] — so it is obvious whose it is and
++ * whose mail it carries. The mail lands in the person's mailbox, like every other address we hand out; what makes
++ * it an agent's inbox is that the service will read it back for that agent.
++ *
++ * One agent has one inbox: asking twice gives the same address.
++ */
++const { sql } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++const { agentInboxAddress, mailboxLocalFor } = require('./addresses');
++const { ensureOwnerMailbox, mailboxAddressFor } = require('./mailboxes');
++const { listForAddress } = require('./messages');
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ agent_id: row.agent_id,
++ address: row.address,
++ owner: row.owner_subject || null,
++ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
++ };
++}
++
++function cleanAgentId(value) {
++ const agent = String(value || '')
++ .toLowerCase()
++ .replace(/[^a-z0-9-]+/g, '-')
++ .replace(/-+/g, '-')
++ .replace(/^-|-$/g, '')
++ .slice(0, 32);
++ if (!agent) {
++ const err = new Error('an agent inbox needs an agent id');
++ err.code = 'mail.bad_agent';
++ err.status = 400;
++ throw err;
++ }
++ return agent;
++}
++
++/** The person's name as an address label: their Network username when they have one, else their mailbox local. */
++function cleanUserLocal(value) {
++ return String(value || '')
++ .toLowerCase()
++ .replace(/[^a-z0-9-]+/g, '-')
++ .replace(/-+/g, '-')
++ .replace(/^-|-$/g, '')
++ .slice(0, 32);
++}
++
++/**
++ * The inbox of an agent, made if it is not there yet.
++ *
++ * The same agent asked twice gets the same address: the person may call the agent by a different name, but the
++ * address stays.
++ */
++async function createAgentInbox({ db, config, owner, agentId, userLocal = null, now = () => Date.now() }) {
++ const agent = cleanAgentId(agentId);
++ return db.tx(async (t) => {
++ const existing = await t.maybe(sql`
++ SELECT id, owner_subject, agent_id, address, created_at
++ FROM mail_agent_inboxes WHERE owner_subject = ${owner} AND agent_id = ${agent}`);
++ if (existing) return shape(existing);
++ await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });
++
++ // <agent>.<user>@<base>: the person's username when they have one, and the mailbox local (their subject
++ // id) when they do not. If the readable address is taken by another person, the stable mailbox local is
++ // tried before giving up, so an inbox never silently becomes somebody else's.
++ const mailboxLocal = mailboxLocalFor(owner);
++ const preferred = cleanUserLocal(userLocal) || mailboxLocal;
++ const candidates = preferred === mailboxLocal
++ ? [agentInboxAddress(agent, preferred, config.addresses.base)]
++ : [agentInboxAddress(agent, preferred, config.addresses.base), agentInboxAddress(agent, mailboxLocal, config.addresses.base)];
++
++ for (const address of candidates) {
++ const row = await t.maybe(sql`
++ INSERT INTO mail_agent_inboxes (id, owner_subject, agent_id, address)
++ VALUES (${`agi_${ids.ulid(now())}`}, ${owner}, ${agent}, ${address})
++ ON CONFLICT DO NOTHING
++ RETURNING id, owner_subject, agent_id, address, created_at`);
++ if (row) return shape(row);
++ const again = await t.maybe(sql`
++ SELECT id, owner_subject, agent_id, address, created_at
++ FROM mail_agent_inboxes WHERE owner_subject = ${owner} AND agent_id = ${agent}`);
++ if (again) return shape(again);
++ }
++ const err = new Error('the agent address is taken; try another agent name');
++ err.code = 'mail.address_taken';
++ err.status = 503;
++ throw err;
++ });
++}
++
++/** The person's agent inboxes, newest first. */
++async function listAgentInboxes({ db, owner }) {
++ const rows = await db.many(sql`
++ SELECT id, owner_subject, agent_id, address, created_at
++ FROM mail_agent_inboxes WHERE owner_subject = ${owner}
++ ORDER BY created_at DESC`);
++ return rows.map(shape);
++}
++
++/** One inbox of this person, or null. Somebody else's inbox is not found. */
++async function findAgentInbox({ db, owner, id }) {
++ const row = await db.maybe(sql`
++ SELECT id, owner_subject, agent_id, address, created_at
++ FROM mail_agent_inboxes WHERE id = ${id} AND owner_subject = ${owner}`);
++ return row ? shape(row) : null;
++}
++
++/** The messages sent to an agent's inbox, newest first. */
++async function agentInboxMessages({ db, stalwart, events, inbox, limit = 20 }) {
++ // findAgentInbox gives the API `owner`; the row itself names `owner_subject`. Read either, so the
++ // internal caller cannot lose the mailbox by passing the shape the API returns.
++ const owner = inbox.owner_subject || inbox.owner;
++ const mailbox = await mailboxAddressFor(db, owner);
++ return listForAddress({
++ db,
++ stalwart,
++ events,
++ owner,
++ address: inbox.address,
++ mailbox,
++ recipient: inbox.address,
++ limit,
++ });
++}
++
++module.exports = { createAgentInbox, listAgentInboxes, findAgentInbox, agentInboxMessages, shape, cleanAgentId, cleanUserLocal };
ddiff --git a/server/domain/burners.js b/server/domain/burners.js
nnew file mode 100644
iindex 0000000..c370915
---- /dev/null
++++ b/server/domain/burners.js
@@@ -0,0 +1,266 @@
++'use strict';
++
++/**
++ * Burners: an address that is thrown away.
++ *
++ * A burner exists to receive one thing — a verification code, a newsletter, a receipt — and to be gone afterwards.
++ * It has an expiry, and when it expires its mail is deleted: not archived, not hidden, deleted.
++ *
++ * A burner may be made without an account. That is the ten-minute burner, and it is the reason for the limits in
++ * front of it: ten an hour from one address, a global cap on live anonymous burners, and five alive at once for a
++ * person who has an account.
++ *
++ * Every burner is its own Stalwart directory account, anonymous or owned. The mail server isolates it; the service
++ * reads that account's mailbox with no recipient matching at all, so no sender-written header can move a message
++ * from one burner to another.
++ */
++const crypto = require('crypto');
++const { sql } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++const { burnerAddress } = require('./addresses');
++const { ensureOwnerMailbox } = require('./mailboxes');
++const { countLiveBurners, countLiveAnonBurners, lockAnonBurners, bump } = require('./limits');
++const { listForAddress, observe } = require('./messages');
++const { extractCode } = require('../stalwart/code');
++
++const EXPIRIES = {
++ '10m': 10 * 60 * 1000,
++ '1h': 60 * 60 * 1000,
++ '1d': 24 * 60 * 60 * 1000,
++ '7d': 7 * 24 * 60 * 60 * 1000,
++};
++const ANON_EXPIRY = '10m';
++const TOKEN_PREFIX = 'mbt_';
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ address: row.address,
++ owner: row.owner_subject || null,
++ expires_at: row.expires_at instanceof Date ? row.expires_at.toISOString() : row.expires_at,
++ first_message_only: Boolean(row.first_message_only),
++ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
++ };
++}
++
++function hashToken(token) {
++ return crypto.createHash('sha256').update(String(token || '')).digest('hex');
++}
++
++function newToken() {
++ return `${TOKEN_PREFIX}${crypto.randomBytes(24).toString('hex')}`;
++}
++
++/** The burner's expiry in milliseconds, or null when the name is not one we know. */
++function expiryMs(name) {
++ return EXPIRIES[String(name || '').trim().toLowerCase()] || null;
++}
++
++/**
++ * Make a burner.
++ *
++ * `owner` is the person, or null for a burner made without an account. `ip` is the address the request came from;
++ * it is what the anonymous limit counts. The token comes back once, in the answer, and only the hash of it is kept.
++ */
++async function createBurner({
++ db, config, owner = null, expires = ANON_EXPIRY, firstMessageOnly = false, ip = null, now = () => Date.now(),
++}) {
++ const key = String(expires || '').trim().toLowerCase();
++ const ttl = expiryMs(key);
++ if (!ttl) {
++ const err = new Error(`expires must be one of ${Object.keys(EXPIRIES).join(', ')}`);
++ err.code = 'mail.bad_expiry';
++ err.status = 400;
++ throw err;
++ }
++ if (!owner && key !== ANON_EXPIRY) {
++ const err = new Error('a burner without an account lives for ten minutes');
++ err.code = 'mail.anon_expiry';
++ err.status = 400;
++ throw err;
++ }
++ const live = owner ? ttl : Math.min(ttl, config.limits.anonBurnerTtlMinutes * 60 * 1000);
++
++ return db.tx(async (t) => {
++ if (owner) {
++ await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });
++ const used = await countLiveBurners(t, owner);
++ if (used >= config.limits.maxLiveBurners) {
++ const err = new Error(`a person may have ${config.limits.maxLiveBurners} burners alive at once`);
++ err.code = 'mail.burner_limit';
++ err.status = 409;
++ throw err;
++ }
++ } else {
++ // A global ceiling first, so one person or a botnet cannot fill the database with live anonymous rows;
++ // then the per-IP creation limit.
++ await lockAnonBurners(t);
++ const liveAnon = await countLiveAnonBurners(t);
++ if (liveAnon >= config.limits.maxLiveAnonBurners) {
++ const err = new Error('the anonymous burner capacity is full; try again later');
++ err.code = 'mail.burner_capacity';
++ err.status = 503;
++ throw err;
++ }
++ const { count } = await bump(t, { key: `burner:ip:${ip || 'unknown'}`, windowMs: 60 * 60 * 1000 });
++ if (count > config.limits.burnersPerIpHour) {
++ const err = new Error('too many burners from this address; try again later');
++ err.code = 'mail.burner_rate';
++ err.status = 429;
++ throw err;
++ }
++ }
++
++ const token = owner ? null : newToken();
++ const guestId = owner ? null : `gst_${ids.ulid(now())}`;
++ const domain = config.addresses.burners[Math.floor(Math.random() * config.addresses.burners.length)];
++ for (let attempt = 0; attempt < 6; attempt++) {
++ const address = burnerAddress(domain);
++ const row = await t.maybe(sql`
++ INSERT INTO mail_burners
++ (id, owner_subject, address, token_hash, guest_id, expires_at, first_message_only)
++ VALUES
++ (${`bnr_${ids.ulid(now())}`}, ${owner}, ${address},
++ ${token ? hashToken(token) : null}, ${guestId},
++ ${new Date(now() + live).toISOString()}, ${Boolean(firstMessageOnly)})
++ ON CONFLICT (address) DO NOTHING
++ RETURNING id, owner_subject, address, expires_at, first_message_only, created_at`);
++ if (!row) continue;
++ return { burner: shape(row), token };
++ }
++ const err = new Error('the address is taken; try again');
++ err.code = 'mail.address_taken';
++ err.status = 503;
++ throw err;
++ });
++}
++
++/** The person's burners, newest first. */
++async function listBurners({ db, owner }) {
++ const rows = await db.many(sql`
++ SELECT id, owner_subject, address, expires_at, first_message_only, created_at
++ FROM mail_burners WHERE owner_subject = ${owner}
++ ORDER BY created_at DESC`);
++ return rows.map(shape);
++}
++
++/** One burner, whoever's it is. The caller decides what it may do with it. */
++async function findBurner({ db, id }) {
++ const row = await db.maybe(sql`
++ SELECT id, owner_subject, address, token_hash, guest_id, expires_at, first_message_only, first_message_at, created_at
++ FROM mail_burners WHERE id = ${id}`);
++ return row || null;
++}
++
++/**
++ * The burner, if the caller may see it.
++ *
++ * A person may see their own burners. The holder of the burner's token may see that burner and nothing else — the
++ * token is what an account-less burner is read with. Anybody else gets nothing: there is no answer that says
++ * "this exists but is not yours".
++ */
++async function authorizeBurner({ db, id, owner = null, token = null }) {
++ const row = await findBurner({ db, id });
++ if (!row) return null;
++ if (owner && row.owner_subject && row.owner_subject === owner) return row;
++ if (token && row.token_hash) {
++ const given = Buffer.from(hashToken(token));
++ const known = Buffer.from(row.token_hash);
++ if (given.length === known.length && crypto.timingSafeEqual(given, known)) return row;
++ }
++ return null;
++}
++
++/** The mailbox of a burner: its own directory account, addressed by the burner address itself. */
++async function mailboxOf({ burner }) {
++ return burner.address;
++}
++
++/**
++ * A burner made for one message stops accepting after it has taken one.
++ *
++ * The mail server decides what an address accepts, and it decides by reading the directory. Once the service has
++ * seen the burner's one message it writes the time down, the address leaves the directory view, and the mail
++ * server is told to forget what it cached: the next letter bounces.
++ */
++async function consumeIfFirstOnly({ db, stalwart, burner, messages = [] }) {
++ if (!burner || !burner.first_message_only || burner.first_message_at) return false;
++ if (!messages.length) return false;
++ const row = await db.maybe(sql`
++ UPDATE mail_burners SET first_message_at = now()
++ WHERE id = ${burner.id} AND first_message_at IS NULL
++ RETURNING id`);
++ if (!row) return false;
++ if (stalwart) {
++ try {
++ await stalwart.invalidateCaches();
++ } catch (err) {
++ (console.warn || console.log)(`[mail] the mail server did not take the cache notice: ${err && err.message}`);
++ }
++ }
++ return true;
++}
++
++/** The messages sent to a burner, newest first. The whole account belongs to this one burner. */
++async function burnerMessages({ db, stalwart, events, burner, limit = 20 }) {
++ const mailbox = await mailboxOf({ burner });
++ const messages = await listForAddress({
++ db,
++ stalwart,
++ events,
++ owner: burner.owner_subject || null,
++ address: burner.address,
++ mailbox,
++ recipient: null,
++ limit,
++ });
++ await consumeIfFirstOnly({ db, stalwart, burner, messages });
++ return messages;
++}
++
++/**
++ * The newest verification code in a burner's mail.
++ *
++ * The body is fetched, read by the extractor and dropped: it is never written down, never logged and never sent
++ * anywhere. A message without a code gives no code.
++ */
++async function burnerCode({ db, stalwart, events, burner }) {
++ const mailbox = await mailboxOf({ burner });
++ if (!mailbox) return null;
++ // No recipient filtering: the account is the burner. A sender's To/Cc/Delivered-To cannot choose another
++ // burner's mailbox, and the account is never shared with another address.
++ const messages = await stalwart.listMessages({ mailboxAddress: mailbox, limit: 5 });
++ if (!messages.length) return null;
++ await observe({ db, events, owner: burner.owner_subject || null, address: burner.address, messages });
++ await consumeIfFirstOnly({ db, stalwart, burner, messages });
++ for (const message of messages) {
++ const body = await stalwart.messageBody({ mailboxAddress: mailbox, id: message.id });
++ const found = extractCode({ subject: message.subject, text: body.text, html: body.html });
++ if (found) {
++ return {
++ code: found.code,
++ message_id: message.id,
++ received_at: message.receivedAt || null,
++ from: (message.from || [])[0] || null,
++ };
++ }
++ }
++ return null;
++}
++
++module.exports = {
++ EXPIRIES,
++ ANON_EXPIRY,
++ createBurner,
++ listBurners,
++ findBurner,
++ authorizeBurner,
++ mailboxOf,
++ burnerMessages,
++ burnerCode,
++ consumeIfFirstOnly,
++ expiryMs,
++ shape,
++ hashToken,
++};
ddiff --git a/server/domain/limits.js b/server/domain/limits.js
nnew file mode 100644
iindex 0000000..1a4de77
---- /dev/null
++++ b/server/domain/limits.js
@@@ -0,0 +1,94 @@
++'use strict';
++
++/**
++ * The limits that keep M1 from being an open relay for spam.
++ *
++ * * a person has twenty masks and five live burners at a time;
++ * * an address on the internet may make ten burners an hour, and the ten-minute burner is the one that is
++ * allowed without an account.
++ *
++ * The counters live in the database, not in memory: a restart must not hand everybody a fresh allowance, and two
++ * processes of the service must agree.
++ */
++const { sql } = require('openvibe-sdk/db');
++
++const HOUR_MS = 60 * 60 * 1000;
++
++async function countMasks(t, ownerSubject) {
++ const row = await t.maybe(sql`SELECT count(*)::int AS n FROM mail_masks WHERE owner_subject = ${ownerSubject}`);
++ return row ? row.n : 0;
++}
++
++async function countLiveBurners(t, ownerSubject) {
++ const row = await t.maybe(sql`
++ SELECT count(*)::int AS n FROM mail_burners
++ WHERE owner_subject = ${ownerSubject}
++ AND expires_at > now()
++ AND (NOT first_message_only OR first_message_at IS NULL)`);
++ return row ? row.n : 0;
++}
++
++/**
++ * Serialize the anonymous-burner cap. A count alone is a soft cap: two requests can both read 1999 and both insert.
++ * This row lock is held until the transaction commits, so the count and the insert that follows it are one decision.
++ */
++async function lockAnonBurners(t) {
++ await t.exec(sql`
++ INSERT INTO mail_settings (key, value, updated_at)
++ VALUES ('anon_burner_lock', '0', now())
++ ON CONFLICT (key) DO NOTHING`);
++ await t.one(sql`SELECT value FROM mail_settings WHERE key = 'anon_burner_lock' FOR UPDATE`);
++}
++
++/** The anonymous burners alive right now: the global ceiling in front of the per-IP limit. */
++async function countLiveAnonBurners(t) {
++ const row = await t.maybe(sql`
++ SELECT count(*)::int AS n FROM mail_burners
++ WHERE owner_subject IS NULL
++ AND expires_at > now()
++ AND (NOT first_message_only OR first_message_at IS NULL)`);
++ return row ? row.n : 0;
++}
++
++/**
++ * Count one thing in a window, and say whether the caller may have one more.
++ *
++ * The counter is one row: when the window is over the row starts again at one. The count is read back in the same
++ * statement that writes it, so two people racing for the last burner do not both get it — the second one sees the
++ * count the first one wrote.
++ */
++async function bump(t, { key, windowMs = HOUR_MS }) {
++ const row = await t.one(sql`
++ INSERT INTO mail_rate_limits (key, window_started_at, count, updated_at)
++ VALUES (${key}, now(), 1, now())
++ ON CONFLICT (key) DO UPDATE SET
++ count = CASE
++ WHEN mail_rate_limits.window_started_at < now() - (${windowMs} * interval '1 millisecond')
++ THEN 1 ELSE mail_rate_limits.count + 1 END,
++ window_started_at = CASE
++ WHEN mail_rate_limits.window_started_at < now() - (${windowMs} * interval '1 millisecond')
++ THEN now() ELSE mail_rate_limits.window_started_at END,
++ updated_at = now()
++ RETURNING count, window_started_at`);
++ return { count: row.count, windowStartedAt: row.window_started_at };
++}
++
++/** The number of burners this address may still make in this hour. */
++async function burnersLeft(t, { ip, limit, windowMs = HOUR_MS }) {
++ if (!ip) return { count: 0, left: limit };
++ const row = await t.maybe(sql`
++ SELECT count FROM mail_rate_limits
++ WHERE key = ${`burner:ip:${ip}`}
++ AND window_started_at >= now() - (${windowMs} * interval '1 millisecond')`);
++ const count = row ? row.count : 0;
++ return { count, left: Math.max(0, limit - count) };
++}
++
++/** The counters nobody is counting any more. */
++async function staleCounters(t, { windowMs = HOUR_MS } = {}) {
++ return t.exec(sql`
++ DELETE FROM mail_rate_limits
++ WHERE window_started_at < now() - (${windowMs} * interval '1 millisecond')`);
++}
++
++module.exports = { HOUR_MS, countMasks, countLiveBurners, countLiveAnonBurners, lockAnonBurners, bump, burnersLeft, staleCounters };
ddiff --git a/server/domain/mailboxes.js b/server/domain/mailboxes.js
nnew file mode 100644
iindex 0000000..ef4e2de
---- /dev/null
++++ b/server/domain/mailboxes.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++/**
++ * Where a person's mail lands.
++ *
++ * Every person who owns an address has one mailbox: an account in the mail server, addressed
++ * <local>@<base domain>. The row is written the first time the person asks for a mask, a burner or an agent
++ * inbox, and it never changes afterwards — the address is the address. Masks and agent inboxes are aliases of
++ * this account, so their mail lands here.
++ *
++ * A burner does not use this table for delivery: the directory view makes the burner its own account, so one
++ * burner's mail can never be seen in another's mailbox even when both have no owner.
++ */
++const { sql } = require('openvibe-sdk/db');
++const { localOf, mailboxAddress, mailboxLocalFor } = require('./addresses');
++
++function wireMailbox(t, { ownerSubject, address }) {
++ return t.maybe(sql`
++ INSERT INTO mail_mailboxes (owner_subject, address)
++ VALUES (${ownerSubject}, ${address})
++ ON CONFLICT (owner_subject) DO NOTHING
++ RETURNING address`);
++}
++
++async function mailboxAddressFor(t, ownerSubject) {
++ const row = await t.maybe(sql`SELECT address FROM mail_mailboxes WHERE owner_subject = ${ownerSubject}`);
++ return row ? row.address : null;
++}
++
++/** The person's own mailbox, written once and never changed. */
++async function ensureMailbox(t, { ownerSubject, address }) {
++ const existing = await mailboxAddressFor(t, ownerSubject);
++ if (existing) return existing;
++ const inserted = await wireMailbox(t, { ownerSubject, address });
++ if (inserted) return inserted.address;
++ const again = await mailboxAddressFor(t, ownerSubject);
++ if (again) return again;
++ // Another address is already taken by somebody else: add a short suffix and try once more.
++ const taken = await t.maybe(sql`SELECT 1 AS taken FROM mail_mailboxes WHERE address = ${address}`);
++ if (!taken) throw new Error('the mailbox address is taken');
++ const alternative = `${localOf(address)}-${String(ownerSubject).slice(-4).toLowerCase()}@${address.slice(address.lastIndexOf('@') + 1)}`;
++ const second = await wireMailbox(t, { ownerSubject, address: alternative });
++ return second ? second.address : alternative;
++}
++
++/** The mailbox of a person, named after their Network subject id. */
++async function ensureOwnerMailbox(t, { ownerSubject, baseDomain }) {
++ return ensureMailbox(t, {
++ ownerSubject,
++ address: mailboxAddress(mailboxLocalFor(ownerSubject), baseDomain),
++ });
++}
++
++module.exports = {
++ wireMailbox,
++ mailboxAddressFor,
++ ensureMailbox,
++ ensureOwnerMailbox,
++};
ddiff --git a/server/domain/masks.js b/server/domain/masks.js
nnew file mode 100644
iindex 0000000..8f44ec2
---- /dev/null
++++ b/server/domain/masks.js
@@@ -0,0 +1,110 @@
++'use strict';
++
++/**
++ * Masks: an address that is the person's own, on a domain of ours, and that nobody outside can guess.
++ *
++ * A mask never forwards anywhere. It delivers into the owner's mailbox in OpenVibe and stops there, and turning it
++ * off makes it bounce: the address leaves the directory view, and the mail server is told to forget what it cached
++ * about it.
++ */
++const { sql } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++const { maskAddress, maskWords } = require('./addresses');
++const { ensureOwnerMailbox } = require('./mailboxes');
++const { countMasks } = require('./limits');
++
++const WORDS_FOR_LABEL = 1;
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ address: row.address,
++ label: row.label || null,
++ purpose: row.purpose || null,
++ enabled: row.enabled !== false,
++ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
++ };
++}
++
++/**
++ * Make a mask for a person.
++ *
++ * The address is <words>.<four characters>@<mask domain>: the words are what the person asked for, the four
++ * characters are what keeps the address unguessable. A collision is retried with new characters — the words stay
++ * the person's.
++ */
++async function createMask({ db, config, events, owner, label = null, purpose = null, now = () => Date.now() }) {
++ const words = maskWords(label || purpose || '');
++ const limit = config.limits.maxMasks;
++
++ return db.tx(async (t) => {
++ const mailbox = await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });
++ const used = await countMasks(t, owner);
++ if (used >= limit) {
++ const err = new Error(`a person may have ${limit} masks`);
++ err.code = 'mail.mask_limit';
++ err.status = 409;
++ throw err;
++ }
++ for (let attempt = 0; attempt < 6; attempt++) {
++ const address = maskAddress(words, config.addresses.mask);
++ const row = await t.maybe(sql`
++ INSERT INTO mail_masks (id, owner_subject, address, label, purpose, enabled)
++ VALUES (${`msk_${ids.ulid(now())}`}, ${owner}, ${address}, ${label || null}, ${purpose || null}, true)
++ ON CONFLICT (address) DO NOTHING
++ RETURNING id, address, label, purpose, enabled, created_at`);
++ if (!row) continue;
++ const mask = shape(row);
++ if (events) await events.outbox.emit(events.maskCreated({ owner, mask }));
++ return mask;
++ }
++ const err = new Error('the address is taken; try again');
++ err.code = 'mail.address_taken';
++ err.status = 503;
++ throw err;
++ });
++}
++
++/** The person's masks, newest first. */
++async function listMasks({ db, owner }) {
++ const rows = await db.many(sql`
++ SELECT id, address, label, purpose, enabled, created_at
++ FROM mail_masks WHERE owner_subject = ${owner}
++ ORDER BY created_at DESC`);
++ return rows.map(shape);
++}
++
++/** One mask of this person, or null. Somebody else's mask is not found, not forbidden. */
++async function findMask({ db, owner, id }) {
++ const row = await db.maybe(sql`
++ SELECT id, address, label, purpose, enabled, created_at
++ FROM mail_masks WHERE id = ${id} AND owner_subject = ${owner}`);
++ return shape(row);
++}
++
++/**
++ * Turn a mask off. The address stops resolving at once — the mail server is told to forget its cache — so the next
++ * message to it bounces instead of sitting in the person's mailbox.
++ */
++async function disableMask({ db, stalwart, owner, id, now = () => Date.now() }) {
++ const mask = await db.tx(async (t) => {
++ const row = await t.maybe(sql`
++ UPDATE mail_masks SET enabled = false
++ WHERE id = ${id} AND owner_subject = ${owner} AND enabled
++ RETURNING id, address, label, purpose, enabled, created_at`);
++ return shape(row);
++ });
++ if (!mask) return null;
++ if (stalwart) {
++ try {
++ await stalwart.invalidateCaches();
++ } catch (err) {
++ // The address is already out of the view; the cache only decides how long the bounce is late.
++ (console.warn || console.log)(`[mail] the mail server did not take the cache notice: ${err && err.message}`);
++ }
++ }
++ return mask;
++}
++
++module.exports = { createMask, listMasks, findMask, disableMask, shape, WORDS_FOR_LABEL };
ddiff --git a/server/domain/messages.js b/server/domain/messages.js
nnew file mode 100644
iindex 0000000..c64a5c3
---- /dev/null
++++ b/server/domain/messages.js
@@@ -0,0 +1,66 @@
++'use strict';
++
++/**
++ * The messages of one of our addresses, and the moment the service first saw them.
++ *
++ * A message is announced once. The service writes down that it has seen a message for an address, and the first
++ * time it sees one it publishes mail.received — the address, the time, the sender, the message id. Nothing else:
++ * no subject, no body, no code.
++ *
++ * The body is read in exactly one place, the code extractor, and it is not kept.
++ */
++const { sql } = require('openvibe-sdk/db');
++
++/** A message as the API gives it: who wrote it, what it is about, when it came, and its id. */
++function summary(message) {
++ return {
++ id: message.id,
++ from: message.from || [],
++ subject: message.subject || null,
++ received_at: message.receivedAt || null,
++ };
++}
++
++/**
++ * Write down the messages that are new to us and announce them.
++ *
++ * The write is the test: the row is only returned when the message was not seen for this address before, so two
++ * readers at the same time announce it once between them.
++ */
++async function observe({ db, events, owner = null, address, messages = [] }) {
++ for (const message of messages) {
++ if (!message || !message.id) continue;
++ // The "I have seen this" row and the event are one transaction: an event cannot exist for a row that did
++ // not commit, and a row cannot commit without its event. On a conflict the row is not new, so no event.
++ await db.tx(async (t) => {
++ const row = await t.maybe(sql`
++ INSERT INTO mail_observed_messages (address, message_id, received_at)
++ VALUES (${address}, ${message.id}, ${message.receivedAt || null})
++ ON CONFLICT (address, message_id) DO NOTHING
++ RETURNING message_id`);
++ if (row && events) await events.outbox.emitIn(t, events.messageReceived({ owner, address, message }));
++ });
++ }
++}
++
++/**
++ * The messages sent to one of our addresses, newest first, read from the mail server over loopback.
++ *
++ * `mailbox` is where the mail landed. A burner passes `recipient: null` because the mailbox is the burner; a mask
++ * or agent inbox passes its address, and the Stalwart client matches it only against the envelope recipient the
++ * server stamped, never a sender-written To, Cc or Delivered-To.
++ */
++async function listForAddress({ db, stalwart, events, owner = null, address, mailbox, recipient = null, limit = 20 }) {
++ if (!mailbox) return [];
++ const messages = await stalwart.listMessages({ mailboxAddress: mailbox, recipient, limit });
++ await observe({ db, events, owner, address, messages });
++ return messages.map(summary);
++}
++
++/** The messages of one address the service has already seen (used by the purge job to know what to delete). */
++async function observedIds({ db, address }) {
++ const rows = await db.many(sql`SELECT message_id FROM mail_observed_messages WHERE address = ${address}`);
++ return rows.map((r) => r.message_id);
++}
++
++module.exports = { summary, observe, listForAddress, observedIds };
ddiff --git a/server/events.js b/server/events.js
nnew file mode 100644
iindex 0000000..e44245c
---- /dev/null
++++ b/server/events.js
@@@ -0,0 +1,87 @@
++'use strict';
++
++/**
++ * The events OpenVibe.Mail publishes.
++ *
++ * Two, and only two:
++ *
++ * mail.mask.created a person made a mask;
++ * mail.received a message arrived for one of our addresses.
++ *
++ * Neither carries a message body, a subject line or a code. mail.received says that mail arrived, for which
++ * address, when, and from whom — that is what an agent needs to decide whether to look, and everything else is
++ * the person's mail and stays where it is.
++ */
++const { ids } = require('openvibe-contracts');
++const { createServiceOutbox } = require('openvibe-sdk/events');
++
++const SOURCE = 'mail';
++
++function createEvents({ db, config, now = () => Date.now(), log = console } = {}) {
++ const outbox = createServiceOutbox({
++ db,
++ source: SOURCE,
++ eventsUrl: config.events.url,
++ networkInternalUrl: config.network.internalUrl,
++ clientId: config.events.clientId,
++ clientSecret: config.events.clientSecret,
++ intervalMs: config.events.intervalMs,
++ now,
++ log,
++ eventTypes: ['mail.mask.created', 'mail.received'],
++ });
++
++ function envelope({ type, actor, subject, payload, timestamp = new Date(now()).toISOString() }) {
++ return {
++ event_id: `evt_${ids.ulid(now())}`,
++ event_type: type,
++ version: 1,
++ source: SOURCE,
++ actor,
++ timestamp,
++ subject,
++ payload,
++ };
++ }
++
++ /** mail.mask.created — the mask exists; nothing about the person beyond the subject id. */
++ function maskCreated({ owner, mask }) {
++ return envelope({
++ type: 'mail.mask.created',
++ actor: { type: 'user', id: owner },
++ subject: { type: 'mail.mask', id: mask.id },
++ payload: {
++ mask_id: mask.id,
++ address: mask.address,
++ label: mask.label || null,
++ purpose: mask.purpose || null,
++ enabled: mask.enabled !== false,
++ },
++ });
++ }
++
++ /**
++ * mail.received — a message arrived. The payload is metadata: the address it was sent to, when it arrived, who
++ * sent it and the id of the message. No subject, no preview, no body.
++ */
++ function messageReceived({ owner, address, message }) {
++ return envelope({
++ type: 'mail.received',
++ actor: owner
++ ? { type: 'user', id: owner }
++ : { type: 'service', id: SOURCE },
++ subject: { type: 'mail.address', id: address },
++ payload: {
++ address,
++ owner: owner || null,
++ message_id: message.id,
++ received_at: message.receivedAt || null,
++ from: message.from || [],
++ },
++ });
++ }
++
++ return { outbox, envelope, maskCreated, messageReceived, start: () => outbox.start(), stop: () => outbox.stop() };
++}
++
++module.exports = { createEvents, SOURCE };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..505a416
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,190 @@
++'use strict';
++
++/**
++ * The API of OpenVibe.Mail, v1.
++ *
++ * POST /api/v1/masks a mask for the person
++ * GET /api/v1/masks the person's masks
++ * GET /api/v1/masks/:id one mask of the person
++ * DELETE /api/v1/masks/:id turn a mask off (it bounces from then on)
++ *
++ * POST /api/v1/burners a burner (with an account, or the ten-minute one without)
++ * GET /api/v1/burners the person's burners
++ * GET /api/v1/burners/:id/messages the messages sent to a burner
++ * GET /api/v1/burners/:id/code the newest verification code in a burner
++ *
++ * POST /api/v1/agent-inboxes an inbox for one of the person's agents
++ * GET /api/v1/agent-inboxes the person's agent inboxes
++ * GET /api/v1/agent-inboxes/:id/messages the messages sent to an agent's inbox
++ *
++ * Two rules run through all of it:
++ *
++ * * what belongs to somebody else is not found. Another person's burner, mask or inbox answers 404, not 403:
++ * the answer must not say that it exists.
++ * * a message body is never in an answer and never in a log line. The list of messages gives the sender, the
++ * subject and the time; the code endpoint gives the code.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++
++const masks = require('../domain/masks');
++const burners = require('../domain/burners');
++const agentInboxes = require('../domain/agent-inboxes');
++
++function createApi({ db, config, stalwart, events, principal, log = console }) {
++ const router = express.Router();
++ const requirePerson = principal.requirePerson;
++
++ /** The person the request acts for, or null. */
++ function ownerOf(req) {
++ return principal.subjectOf(req.principal);
++ }
++
++ /** The burner's secret, when the caller brought one. */
++ function burnerTokenOf(req) {
++ const header = req.get('x-mail-burner-token');
++ return header ? String(header).trim() : null;
++ }
++
++ /** An async route that answers even when it throws: Express 4 does not catch a rejected promise. */
++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
++
++ /** The house's problem answer: RFC 9457, with the request's trace ids. */
++ function fail(req, res, status, code, detail) {
++ return http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ }
++
++ // ---- masks -------------------------------------------------------------------------------
++
++ router.post('/api/v1/masks', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
++ const owner = ownerOf(req);
++ const body = req.body || {};
++ try {
++ const mask = await masks.createMask({
++ db,
++ config,
++ events,
++ owner,
++ label: typeof body.label === 'string' ? body.label.slice(0, 120) : null,
++ purpose: typeof body.purpose === 'string' ? body.purpose.slice(0, 120) : null,
++ });
++ return res.status(201).json({ mask });
++ } catch (err) {
++ if (err && err.status) return fail(req, res, err.status, err.code || 'mail.error', err.message);
++ throw err;
++ }
++ }));
++
++ router.get('/api/v1/masks', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ const list = await masks.listMasks({ db, owner: ownerOf(req) });
++ return res.json({ masks: list });
++ }));
++
++ router.get('/api/v1/masks/:id', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ const mask = await masks.findMask({ db, owner: ownerOf(req), id: req.params.id });
++ if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
++ return res.json({ mask });
++ }));
++
++ router.delete('/api/v1/masks/:id', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
++ const mask = await masks.disableMask({ db, stalwart, owner: ownerOf(req), id: req.params.id });
++ if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
++ return res.json({ mask });
++ }));
++
++ // ---- burners -----------------------------------------------------------------------------
++
++ /**
++ * A burner. With a token it belongs to the person; without one it is the ten-minute burner, limited by the
++ * address the request came from.
++ */
++ router.post('/api/v1/burners', wrap(async (req, res) => {
++ const owner = ownerOf(req);
++ const body = req.body || {};
++ const ip = req.ip || (req.socket && req.socket.remoteAddress) || null;
++ try {
++ const { burner, token } = await burners.createBurner({
++ db,
++ config,
++ owner,
++ expires: body.expires || burners.ANON_EXPIRY,
++ firstMessageOnly: Boolean(body.first_message_only),
++ ip,
++ });
++ return res.status(201).json({ burner, token });
++ } catch (err) {
++ if (err && err.status) return fail(req, res, err.status, err.code || 'mail.error', err.message);
++ throw err;
++ }
++ }));
++
++ router.get('/api/v1/burners', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ const list = await burners.listBurners({ db, owner: ownerOf(req) });
++ return res.json({ burners: list });
++ }));
++
++ /** The messages of a burner: the person's own, or the one whose token was sent. */
++ router.get('/api/v1/burners/:id/messages', wrap(async (req, res) => {
++ const burner = await burners.authorizeBurner({
++ db,
++ id: req.params.id,
++ owner: ownerOf(req),
++ token: burnerTokenOf(req),
++ });
++ if (!burner) return fail(req, res, 404, 'mail.not_found', 'That burner does not exist.');
++ const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
++ const messages = await burners.burnerMessages({ db, stalwart, events, burner, limit });
++ return res.json({ messages });
++ }));
++
++ /** The newest verification code in a burner. */
++ router.get('/api/v1/burners/:id/code', wrap(async (req, res) => {
++ const burner = await burners.authorizeBurner({
++ db,
++ id: req.params.id,
++ owner: ownerOf(req),
++ token: burnerTokenOf(req),
++ });
++ if (!burner) return fail(req, res, 404, 'mail.not_found', 'That burner does not exist.');
++ const found = await burners.burnerCode({ db, stalwart, events, burner });
++ if (!found) return fail(req, res, 404, 'mail.no_code', 'No verification code has arrived yet.');
++ return res.json(found);
++ }));
++
++ // ---- agent inboxes -----------------------------------------------------------------------
++
++ router.post('/api/v1/agent-inboxes', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
++ const owner = ownerOf(req);
++ const body = req.body || {};
++ try {
++ const inbox = await agentInboxes.createAgentInbox({
++ db,
++ config,
++ owner,
++ agentId: body.agent_id,
++ userLocal: req.principal ? req.principal.username : null,
++ });
++ return res.status(201).json({ agent_inbox: inbox });
++ } catch (err) {
++ if (err && err.status) return fail(req, res, err.status, err.code || 'mail.error', err.message);
++ throw err;
++ }
++ }));
++
++ router.get('/api/v1/agent-inboxes', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ const list = await agentInboxes.listAgentInboxes({ db, owner: ownerOf(req) });
++ return res.json({ agent_inboxes: list });
++ }));
++
++ router.get('/api/v1/agent-inboxes/:id/messages', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ const inbox = await agentInboxes.findAgentInbox({ db, owner: ownerOf(req), id: req.params.id });
++ if (!inbox) return fail(req, res, 404, 'mail.not_found', 'That inbox does not exist.');
++ const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
++ const messages = await agentInboxes.agentInboxMessages({ db, stalwart, events, inbox, limit });
++ return res.json({ messages });
++ }));
++
++ return router;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..9c19839
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,154 @@
++'use strict';
++
++/**
++ * Who is calling /api/v1 — req.principal:
++ *
++ * { kind: 'user', subject: 'usr_…', role, username }
++ * a person, with their Network session token as a Bearer.
++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', subject: 'usr_…' | null, capabilities, claims }
++ * a Network app, agent or service token for audience openvibe.mail. When the token names the person it acts
++ * for (`on_behalf_of`), the app acts for that person and is held to that person's limits. When it does not,
++ * it acts for itself and may read nothing of theirs.
++ * { kind: 'anonymous' }
++ * nobody. The only thing an anonymous caller may do is ask for a burner, and only within the limits that are
++ * counted by the address the request came from.
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to
++ * anonymous. A typed token (Network's `typ` or `purpose` claim) is not a session and is refused here as it is
++ * everywhere else in the house.
++ */
++const { serviceAuth, http, ids } = require('openvibe-contracts');
++
++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
++const USER_SUB = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++// The capabilities Network may grant to an app that acts for a person. A person with their own token needs none.
++const CAPABILITIES = {
++ write: 'mail.addresses.write',
++ read: 'mail.messages.read',
++};
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++function roleClaim(claims) {
++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
++}
++
++function usernameClaim(claims) {
++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
++}
++
++function requesterOfSub(sub) {
++ if (String(sub).startsWith('svc:')) return `service:${String(sub).slice(4)}`;
++ return String(sub);
++}
++
++function capabilitiesOf(claims) {
++ const cap = claims && claims.cap;
++ if (Array.isArray(cap)) return cap.filter((c) => typeof c === 'string');
++ if (typeof cap === 'string') return cap.split(/[\s,]+/).filter(Boolean);
++ return [];
++}
++
++function createPrincipal({ config, keys }) {
++ async function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) return { principal: { kind: 'anonymous' } };
++ const token = header.slice(7).trim();
++ if (!token) return { error: [401, 'token.invalid', 'the Bearer token is empty'] };
++
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ const publicKey = await keys.pemForToken(token);
++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.network.audience });
++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
++ const claims = r.claims || {};
++ const behalf = USER_SUB.test(String(claims.on_behalf_of || '')) ? claims.on_behalf_of : null;
++ return {
++ principal: {
++ kind: 'app',
++ requester: requesterOfSub(claims.sub),
++ subject: behalf,
++ capabilities: capabilitiesOf(claims),
++ claims,
++ },
++ };
++ }
++
++ const v = await keys.verifyUser(token, { issuer: config.network.issuer, audience: config.network.sessionAudience });
++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
++ if (!ids.isSubjectId('user', v.claims.subject_id)) {
++ return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
++ }
++ return {
++ principal: {
++ kind: 'user',
++ subject: v.claims.subject_id,
++ requester: `user:${v.claims.subject_id}`,
++ capabilities: CAPABILITIES,
++ role: roleClaim(v.claims),
++ username: usernameClaim(v.claims),
++ token,
++ },
++ };
++ }
++
++ async function middleware(req, res, next) {
++ const r = await resolve(req);
++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
++ req.principal = r.principal;
++ return next();
++ }
++
++ /**
++ * The person the request acts for, or null.
++ *
++ * An app that names the person it acts for acts for them; an app that does not acts for itself and has no
++ * person of its own to read.
++ */
++ function subjectOf(principal) {
++ if (!principal) return null;
++ if (principal.kind === 'user') return principal.subject;
++ if (principal.kind === 'app' && principal.subject) return principal.subject;
++ return null;
++ }
++
++ /** A route for a person: their token, or an app acting for them with the right capability. */
++ function requirePerson(capability) {
++ return function guard(req, res, next) {
++ const principal = req.principal || { kind: 'anonymous' };
++ if (principal.kind === 'anonymous') {
++ return http.sendProblem(res, 401, 'token.required', {
++ detail: 'Send a person\'s Network token as a Bearer.',
++ ctx: req.ov,
++ });
++ }
++ if (principal.kind === 'user') return next();
++ if (principal.kind === 'app') {
++ if (!principal.subject) {
++ return http.sendProblem(res, 403, 'mail.person_required', {
++ detail: 'This route is for a person. An app token that does not name a person has nothing here.',
++ ctx: req.ov,
++ });
++ }
++ if (capability && !(principal.capabilities || []).includes(capability)) {
++ return http.sendProblem(res, 403, 'mail.capability_missing', {
++ detail: `The app needs the ${capability} capability for this route.`,
++ ctx: req.ov,
++ });
++ }
++ return next();
++ }
++ return http.sendProblem(res, 403, 'mail.person_required', { detail: 'This route is for a person.', ctx: req.ov });
++ };
++ }
++
++ return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };
++}
++
++module.exports = { createPrincipal, CAPABILITIES, decodePayload };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..53b8bb9
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,77 @@
++'use strict';
++
++/**
++ * OpenVibe.Mail — the process.
++ *
++ * The order is the order of the dependencies: the database first (the schema is applied at boot), then the mail
++ * server (the domains have to be known before the first message arrives), then the events relay and the purge job,
++ * and only then the door. There is no pool mailbox: every burner is its own directory account.
++ */
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createApp } = require('./app');
++const { createEvents } = require('./events');
++const { createStalwartClient } = require('./stalwart/client');
++const { startPurgeJob } = require('./jobs/purge');
++
++async function main() {
++ const config = configLib.load();
++ const log = console;
++
++ const store = await openStore(config, { log });
++ const db = store.db;
++
++ const stalwart = createStalwartClient({ config, log });
++
++ // The domains: the base domain, the mask domain and every burner domain. A burner domain that the mail server
++ // does not know would bounce, so it is added here — a new domain is a configuration change, not a code change.
++ const domains = [config.addresses.base, config.addresses.mask, ...config.addresses.burners];
++ for (const domain of domains) {
++ try {
++ const added = await stalwart.ensureDomain(domain);
++ if (added) log.info(`[OpenVibe.Mail] the mail server now knows ${domain}`);
++ } catch (err) {
++ log.warn(`[OpenVibe.Mail] the mail server did not take ${domain}: ${err && err.message}`);
++ }
++ }
++
++ const events = createEvents({ db, config, log });
++ events.start();
++
++ const purge = startPurgeJob({
++ db,
++ stalwart,
++ events,
++ intervalMs: config.jobs.purgeIntervalMs,
++ log,
++ });
++
++ const { app } = await createApp({ config, store, stalwart, events, log });
++
++ const server = app.listen(config.port, config.host, () => {
++ log.info(`[OpenVibe.Mail] listening on ${config.host}:${config.port}`);
++ });
++
++ const shutdown = async (signal) => {
++ log.info(`[OpenVibe.Mail] ${signal}: stopping`);
++ purge.stop();
++ events.stop();
++ server.close(() => {
++ store.close().finally(() => process.exit(0));
++ });
++ setTimeout(() => process.exit(0), 10_000).unref();
++ };
++ process.on('SIGTERM', () => shutdown('SIGTERM'));
++ process.on('SIGINT', () => shutdown('SIGINT'));
++
++ return { server, store, stalwart, events, purge };
++}
++
++if (require.main === module) {
++ main().catch((err) => {
++ console.error('[OpenVibe.Mail] the service did not start:', err && err.message ? err.message : err);
++ process.exit(1);
++ });
++}
++
++module.exports = { main };
ddiff --git a/server/jobs/purge.js b/server/jobs/purge.js
nnew file mode 100644
iindex 0000000..77e9fa0
---- /dev/null
++++ b/server/jobs/purge.js
@@@ -0,0 +1,173 @@
++'use strict';
++
++/**
++ * The job that keeps M1 honest: a burner's mail is deleted when the burner expires.
++ *
++ * The work is due-based, not a sweep. Every run asks the database what is due — the burners whose expiry has
++ * passed — and does that, and nothing else. There is no weekly round over the whole table, and a service that was
++ * down for an hour deletes what expired in that hour the minute it starts, not a week later.
++ *
++ * The job also watches the burners that were made for one message only: as soon as one of them has taken its
++ * message, it stops accepting, and the mail server is told to forget what it cached.
++ */
++const { sql } = require('openvibe-sdk/db');
++const { staleCounters } = require('../domain/limits');
++const { observe } = require('../domain/messages');
++
++const LEASE_KEY = 'purge_lease';
++const LEASE_MS = 30_000;
++
++/** Take the lease, so two instances of the service do not do the same work twice. */
++async function takeLease(t, { now = Date.now(), leaseMs = LEASE_MS } = {}) {
++ const row = await t.maybe(sql`
++ INSERT INTO mail_settings (key, value, updated_at)
++ VALUES (${LEASE_KEY}, ${new Date(now).toISOString()}, now())
++ ON CONFLICT (key) DO UPDATE
++ SET value = EXCLUDED.value, updated_at = now()
++ WHERE mail_settings.updated_at < now() - (${leaseMs} * interval '1 millisecond')
++ RETURNING value`);
++ return Boolean(row);
++}
++
++/** The burners whose time is up. */
++async function dueBurners(t, { now = Date.now(), limit = 200 } = {}) {
++ return t.many(sql`
++ SELECT id, owner_subject, address, expires_at, first_message_only, first_message_at
++ FROM mail_burners
++ WHERE expires_at <= ${new Date(now).toISOString()}
++ ORDER BY expires_at
++ LIMIT ${limit}`);
++}
++
++/** The burners that were made for one message and have not taken it yet. */
++async function waitingBurners(t, { limit = 50 } = {}) {
++ return t.many(sql`
++ SELECT id, owner_subject, address, expires_at, first_message_only, first_message_at
++ FROM mail_burners
++ WHERE first_message_only
++ AND first_message_at IS NULL
++ AND expires_at > now()
++ ORDER BY created_at
++ LIMIT ${limit}`);
++}
++
++/**
++ * Delete one burner's mail, its Stalwart account and the burner row.
++ *
++ * The burner's account contains its mail and nothing else, so the delete cannot touch a person's other mail and
++ * needs no recipient matching. A failure keeps the row (and with it the next run's work): the mail must not outlive
++ * the address because the mail server was down for one minute.
++ */
++async function purgeBurner({ db, stalwart, burner }) {
++ let deleted = 0;
++ if (stalwart) {
++ // A burner can have more than one page; delete every page before the account itself. The account destroy
++ // removes anything left, so the address cannot keep a hidden copy after the row is gone.
++ for (let page = 0; page < 200; page++) {
++ const messages = await stalwart.listMessages({ mailboxAddress: burner.address, limit: 100 });
++ if (!messages.length) break;
++ deleted += await stalwart.deleteMessages({ mailboxAddress: burner.address, ids: messages.map((m) => m.id) });
++ if (messages.length < 100) break;
++ }
++ await stalwart.destroyAccount({ mailboxAddress: burner.address });
++ }
++ await db.tx(async (t) => {
++ await t.exec(sql`DELETE FROM mail_observed_messages WHERE address = ${burner.address}`);
++ await t.exec(sql`DELETE FROM mail_burners WHERE id = ${burner.id}`);
++ });
++ return deleted;
++}
++
++/**
++ * One run of the job: what is due, and only that.
++ *
++ * Returns what it did, so the run can be logged without saying anything about the mail itself.
++ */
++async function runDue({ db, stalwart, events, now = () => Date.now(), log = console } = {}) {
++ const report = { expired: 0, messagesDeleted: 0, consumed: 0, counters: 0, skipped: false };
++ const held = await db.tx(async (t) => takeLease(t, { now: now() }));
++ if (!held) {
++ report.skipped = true;
++ return report;
++ }
++
++ const expired = await db.tx(async (t) => dueBurners(t, { now: now() }));
++ for (const burner of expired) {
++ try {
++ report.messagesDeleted += await purgeBurner({ db, stalwart, burner });
++ report.expired += 1;
++ } catch (err) {
++ (log.warn || log.log).call(log, `[mail] the burner ${burner.id} was not purged: ${err && err.message}`);
++ }
++ }
++ // The view no longer resolves the expired addresses (whether or not the delete succeeded), but the mail
++ // server's account cache still holds what it learned: without this, a just-expired burner could keep receiving
++ // until the cache aged out. Invalidate for every due address, not only the ones purged this run.
++ if (expired.length && stalwart) {
++ try {
++ await stalwart.invalidateCaches();
++ } catch (err) {
++ (log.warn || log.log).call(log, `[mail] the mail server did not take the expiry cache notice: ${err && err.message}`);
++ }
++ }
++
++ const waiting = await db.tx(async (t) => waitingBurners(t));
++ for (const burner of waiting) {
++ if (!stalwart) continue;
++ try {
++ const messages = await stalwart.listMessages({ mailboxAddress: burner.address, limit: 5 });
++ if (!messages.length) continue;
++ await observe({ db, events, owner: burner.owner_subject || null, address: burner.address, messages });
++ await db.exec(sql`
++ UPDATE mail_burners SET first_message_at = now()
++ WHERE id = ${burner.id} AND first_message_at IS NULL`);
++ await stalwart.invalidateCaches();
++ report.consumed += 1;
++ } catch (err) {
++ (log.warn || log.log).call(log, `[mail] the burner ${burner.id} was not checked: ${err && err.message}`);
++ }
++ }
++
++ report.counters = await db.tx(async (t) => staleCounters(t));
++ return report;
++}
++
++/**
++ * The scheduler.
++ *
++ * A chain of timeouts, not an interval: the next run is planned after the last one finished, so a slow mail
++ * server cannot pile runs on top of each other.
++ */
++function startPurgeJob({ db, stalwart, events, intervalMs = 60_000, now = () => Date.now(), log = console } = {}) {
++ let stopped = false;
++ let timer = null;
++
++ async function tick() {
++ if (stopped) return;
++ try {
++ const report = await runDue({ db, stalwart, events, now, log });
++ if (!report.skipped && (report.expired || report.consumed)) {
++ (log.info || log.log).call(log, `[mail] purge: ${report.expired} burners expired, ${report.messagesDeleted} messages deleted`);
++ }
++ } catch (err) {
++ (log.warn || log.log).call(log, `[mail] the purge job failed: ${err && err.message}`);
++ }
++ if (!stopped) {
++ timer = setTimeout(tick, intervalMs);
++ if (timer.unref) timer.unref();
++ }
++ }
++
++ timer = setTimeout(tick, intervalMs);
++ if (timer.unref) timer.unref();
++
++ return {
++ stop() {
++ stopped = true;
++ if (timer) clearTimeout(timer);
++ },
++ runNow: () => runDue({ db, stalwart, events, now, log }),
++ };
++}
++
++module.exports = { runDue, startPurgeJob, takeLease, dueBurners, waitingBurners, purgeBurner, LEASE_KEY };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..3127ea6
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * What the service tells the outside about itself: is it alive, is it ready, and what is it doing.
++ *
++ * The health answer says nothing about mail and nothing about anybody. The readiness answer asks two questions —
++ * can the database answer, and can the mail server answer — and refuses when either cannot, so a half-up service
++ * does not take traffic.
++ */
++function createServiceReadiness({ db, config, stalwart, version = null }) {
++ async function handler(req, res) {
++ const detail = { service: 'mail', version };
++ let ok = true;
++
++ try {
++ const ready = await db.ready();
++ detail.database = ready.ok ? ready.detail : { error: ready.error };
++ if (!ready.ok) ok = false;
++ } catch (err) {
++ detail.database = { error: err && err.message };
++ ok = false;
++ }
++
++ if (stalwart) {
++ try {
++ const domains = await stalwart.listDomains();
++ detail.mail_server = { ok: true, domains: domains.length };
++ } catch (err) {
++ detail.mail_server = { ok: false, error: err && err.message ? String(err.message).slice(0, 120) : 'unreachable' };
++ ok = false;
++ }
++ }
++
++ if (config && config.events) detail.events = config.events.url ? 'configured' : 'off';
++
++ res.status(ok ? 200 : 503).json({ status: ok ? 'ok' : 'not_ready', ...detail });
++ return null;
++ }
++
++ return { handler };
++}
++
++module.exports = { createServiceReadiness };
ddiff --git a/server/stalwart/client.js b/server/stalwart/client.js
nnew file mode 100644
iindex 0000000..68e2824
---- /dev/null
++++ b/server/stalwart/client.js
@@@ -0,0 +1,314 @@
++'use strict';
++
++/**
++ * The mail server (Stalwart) over loopback: JMAP for reading a person's mail, the management API for the domains
++ * and for the cache.
++ *
++ * Two things are worth knowing about the shape of this client.
++ *
++ * The service reads a person's mailbox as the server's administrator and names the account it wants, because the
++ * mail server has one account per person and the service has to be able to read any of them. The administrator
++ * credential never leaves this file and never goes near a log line.
++ *
++ * The service never writes a message body anywhere. `messageBody` fetches one blob to read it, hands the text to
++ * the code extractor, and forgets it: the body of a message is not a log line, not an event and not a database row.
++ */
++const { URL } = require('url');
++
++const USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:blob'];
++const ADDRESS_RE = /^[^\s@<>,;:"]+@[^\s@<>,;:"]+$/;
++
++function basicAuth(user, password) {
++ return 'Basic ' + Buffer.from(`${user}:${password}`, 'utf8').toString('base64');
++}
++
++class StalwartError extends Error {
++ constructor(message, { status = 0, code = '' } = {}) {
++ super(message);
++ this.name = 'StalwartError';
++ this.status = status;
++ this.code = code;
++ }
++}
++
++function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(config.stalwart.url || '').replace(/\/+$/, '');
++ const auth = basicAuth(config.stalwart.adminUser || 'admin', config.stalwart.adminPassword || '');
++ const timeoutMs = config.stalwart.requestTimeoutMs || 20_000;
++ const accountCache = new Map(); // mailbox address → account id
++
++ async function jmap(methodCalls, { accountId = null } = {}) {
++ const body = { using: USING, methodCalls: methodCalls.map(([name, args, id]) => [name, args, id]) };
++ const res = await fetchImpl(`${base}/jmap`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: auth },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ const text = await res.text();
++ let parsed = null;
++ try { parsed = JSON.parse(text); } catch { /* the error below says more */ }
++ if (!res.ok || !parsed || !Array.isArray(parsed.methodResponses)) {
++ const detail = (parsed && (parsed.detail || parsed.description)) || `HTTP ${res.status}`;
++ throw new StalwartError(`the mail server refused a request: ${String(detail).slice(0, 200)}`, { status: res.status });
++ }
++ return parsed.methodResponses.map(([name, result, id]) => ({ name, result, id }));
++ }
++
++ /** One method call, unwrapped: the result or a thrown error. */
++ async function one(name, args, id = '0') {
++ const [response] = await jmap([[name, args, id]]);
++ if (!response) throw new StalwartError('the mail server answered nothing');
++ if (response.name === 'error') {
++ throw new StalwartError(`the mail server refused ${name}: ${response.result.description || response.result.type}`, {
++ status: 0,
++ code: response.result.type || '',
++ });
++ }
++ return response.result;
++ }
++
++ let domainCache = null;
++ async function domainNames() {
++ if (domainCache) return domainCache;
++ const domains = await listDomains();
++ domainCache = new Map(domains.map((d) => [String(d.id), String(d.name || '').toLowerCase()]));
++ return domainCache;
++ }
++
++ /**
++ * The mail server's account id for a mailbox address. Stalwart names an account by its local part and points it
++ * at a domain, so the full address is `name@domain`; a burner and a person can share a local part on different
++ * domains. The account is created by the mail server itself when a directory row first receives mail, so this
++ * may answer null before the first message.
++ */
++ async function accountIdFor(mailboxAddress) {
++ const key = String(mailboxAddress || '').toLowerCase();
++ if (!ADDRESS_RE.test(key)) throw new StalwartError('a mailbox address is needed');
++ if (accountCache.has(key)) return accountCache.get(key);
++ const local = key.slice(0, key.lastIndexOf('@'));
++ const domains = await domainNames();
++ let position = 0;
++ // An account list can be longer than one page once there are hundreds of burners. Walk it until the
++ // matching full address is found; if two accounts claim it, refuse rather than read the wrong mailbox.
++ for (let page = 0; page < 20; page++) {
++ const result = await one('x:Account/query', { filter: {}, limit: 500, position }, 'q');
++ const ids = result.ids || [];
++ if (!ids.length) return null;
++ const accounts = await one('x:Account/get', {
++ ids,
++ properties: ['id', 'name', 'domainId', 'aliases'],
++ }, 'g');
++ const matches = (accounts.list || []).filter((a) => {
++ const name = String(a.name || '').toLowerCase();
++ if (name.includes('@')) return name === key;
++ if (name !== local) return false;
++ const domain = a.domainId === undefined || a.domainId === null
++ ? null
++ : domains.get(String(a.domainId));
++ // If the server did not give the domain, one local-only match is unambiguous; more than one is not.
++ return domain ? `${name}@${domain}` === key : true;
++ });
++ if (matches.length === 1) {
++ accountCache.set(key, matches[0].id);
++ return matches[0].id;
++ }
++ if (matches.length > 1) return null;
++ if (ids.length < 500) return null;
++ position += ids.length;
++ }
++ return null;
++ }
++
++ /**
++ * The envelope recipient Stalwart stamped at delivery, and only that.
++ *
++ * Stalwart prepends `Delivered-To` for the SMTP envelope recipient. A sender can put their own `Delivered-To`,
++ * `To` or `Cc` in the message, so the first `Delivered-To` is the only one that says where the server actually
++ * delivered. A message with no server stamp has no attributable recipient and matches nothing.
++ */
++ function stampedRecipient(message) {
++ const headers = message.headers || [];
++ const first = headers.find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to');
++ if (!first) return null;
++ let value = String(first.value || '').trim().toLowerCase();
++ const angle = /<([^>]+)>/.exec(value);
++ if (angle) value = angle[1].trim().toLowerCase();
++ return value || null;
++ }
++
++ /**
++ * The newest messages of a mailbox. Metadata only: the id, the sender, the subject and when it arrived. There
++ * is no preview and no body: the body is read once, by the code extractor, and forgotten.
++ *
++ * A burner's mailbox is the burner, so `recipient` is null and every message in the account is its own. A mask
++ * or agent inbox shares the person's mailbox, so `recipient` is set and a message is kept only when the first
++ * `Delivered-To` header — the one Stalwart stamps — names it. Sender-written To/Cc/Delivered-To is never read.
++ */
++ async function listMessages({ mailboxAddress, recipient = null, limit = 20 }) {
++ const accountId = await accountIdFor(mailboxAddress);
++ if (!accountId) return [];
++ const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
++ const perPage = Math.min(Math.max(Number(limit) || 20, 1), 100);
++ // A shared mailbox needs a larger window to find an address's mail; the account never changes the answer
++ // for a burner. 500 is a bound, not a correctness rule: the match is on the server's stamp.
++ const scan = wanted ? Math.min(Math.max(perPage * 25, 100), 500) : perPage;
++ const queried = await one('Email/query', {
++ accountId,
++ filter: {},
++ limit: scan,
++ sort: [{ property: 'receivedAt', isAscending: false }],
++ }, 'q');
++ const ids = queried.ids || [];
++ if (!ids.length) return [];
++ const properties = ['id', 'from', 'subject', 'receivedAt', 'keywords'];
++ if (wanted) properties.push('headers');
++ const got = await one('Email/get', { accountId, ids, properties }, 'e');
++ const messages = [];
++ for (const m of got.list || []) {
++ if (wanted && stampedRecipient(m) !== wanted) continue;
++ messages.push({
++ id: m.id,
++ from: (m.from || []).map((a) => a.email).filter(Boolean),
++ subject: m.subject || null,
++ receivedAt: m.receivedAt || null,
++ spam: Boolean(m.keywords && m.keywords.$junk),
++ });
++ if (messages.length >= perPage) break;
++ }
++ return messages;
++ }
++
++ /**
++ * Delete messages from a mailbox: a burner's mail when it expires, and nothing else. A burner's mailbox is its
++ * own account, so every id in it belongs to that burner.
++ */
++ async function deleteMessages({ mailboxAddress, ids }) {
++ const list = (ids || []).filter(Boolean);
++ if (!list.length) return 0;
++ const accountId = await accountIdFor(mailboxAddress);
++ if (!accountId) return 0;
++ const result = await one('Email/set', { accountId, destroy: list }, 's');
++ return (result.destroyed || []).length;
++ }
++
++ /**
++ * The body of one message, as text and as html. Used by the code extractor and by nothing else: the caller
++ * must not keep it.
++ */
++ async function messageBody({ mailboxAddress, id }) {
++ const accountId = await accountIdFor(mailboxAddress);
++ if (!accountId) return { text: '', html: '' };
++ const got = await one('Email/get', {
++ accountId,
++ ids: [id],
++ properties: ['id', 'textBody', 'htmlBody'],
++ }, 'e');
++ const email = (got.list || [])[0];
++ if (!email) return { text: '', html: '' };
++ // The same blob may be named by both lists (a text-only message is both its text and its html body).
++ // Keep the first name and its part type: the type is what says whether the bytes are html.
++ const parts = new Map();
++ for (const [group, asHtml] of [[email.textBody, false], [email.htmlBody, true]]) {
++ for (const part of group || []) {
++ if (!part || !part.blobId || parts.has(part.blobId)) continue;
++ parts.set(part.blobId, { asHtml, type: String(part.type || '').toLowerCase() });
++ }
++ }
++ if (!parts.size) return { text: '', html: '' };
++ const blobs = await one('Blob/get', {
++ accountId,
++ ids: [...parts.keys()],
++ properties: ['data:asText', 'type'],
++ }, 'b');
++ let text = '';
++ let html = '';
++ for (const blob of blobs.list || []) {
++ const value = blob['data:asText'];
++ if (typeof value !== 'string') continue;
++ const part = parts.get(blob.id) || { asHtml: false, type: String(blob.type || '').toLowerCase() };
++ const isHtml = (part.asHtml && part.type !== 'text/plain') || (!part.type && /<[a-z][\s\S]*>/i.test(value));
++ if (isHtml) html += `${html ? '\n' : ''}${value}`;
++ else text += `${text ? '\n' : ''}${value}`;
++ }
++ return { text, html };
++ }
++
++ /**
++ * Delete the Stalwart account a burner owned. The purge job removes the directory row first (so the address
++ * stops resolving), then the account's mail and, here, the empty account itself. An account that never received
++ * anything does not exist and needs no deletion.
++ */
++ async function destroyAccount({ mailboxAddress }) {
++ const key = String(mailboxAddress || '').toLowerCase();
++ const accountId = await accountIdFor(key);
++ if (!accountId) return false;
++ const result = await one('x:Account/set', { destroy: [accountId] }, 'd');
++ const destroyed = (result.destroyed || []).map(String).includes(String(accountId));
++ if (!destroyed) {
++ const why = result && result.notDestroyed && result.notDestroyed[accountId];
++ throw new StalwartError(`the mail server did not destroy the burner account: ${(why && (why.description || why.type)) || 'refused'}`);
++ }
++ accountCache.delete(key);
++ // The directory row is already gone; the account must be gone too, or the address could still resolve
++ // through the server's account cache. If it is still there, keep the burner row for the next purge run.
++ if (await accountIdFor(key)) {
++ throw new StalwartError(`the burner account ${key} still exists after destroy`);
++ }
++ return true;
++ }
++
++ /** Forget the directory's answers, so an address that was turned off stops resolving at once. */
++ async function invalidateCaches() {
++ const result = await one('x:Action/set', { create: { invalidate: { '@type': 'InvalidateCaches' } } }, 'a');
++ if (result && result.notCreated && Object.keys(result.notCreated).length) {
++ const detail = Object.values(result.notCreated)[0];
++ throw new StalwartError(`the mail server did not invalidate its cache: ${(detail && detail.description) || 'refused'}`);
++ }
++ return true;
++ }
++
++ /** Make sure the mail server accepts a domain (a burner domain that was added to the configuration). */
++ async function ensureDomain(name) {
++ const domain = String(name || '').trim().toLowerCase();
++ if (!domain) throw new StalwartError('a domain name is needed');
++ const existing = await listDomains();
++ if (existing.some((d) => String(d.name || '').toLowerCase() === domain)) return false;
++ const result = await one('x:Domain/set', {
++ create: {
++ [domain]: { '@type': 'Domain', name: domain, enable: true, allowRelaying: false },
++ },
++ }, 'd');
++ const refused = result && result.notCreated && result.notCreated[domain];
++ if (refused) {
++ throw new StalwartError(`the mail server refused the domain ${domain}: ${refused.description || refused.type || 'refused'}`);
++ }
++ domainCache = null;
++ return Boolean(result && result.created && result.created[domain]);
++ }
++
++ /** Which domains the mail server knows, so a new burner domain can be checked against it. */
++ async function listDomains() {
++ const result = await one('x:Domain/query', { filter: {}, limit: 500 }, 'q');
++ const ids = result.ids || [];
++ if (!ids.length) return [];
++ const got = await one('x:Domain/get', { ids, properties: ['id', 'name', 'enable'] }, 'g');
++ return got.list || [];
++ }
++
++ return {
++ jmap,
++ one,
++ accountIdFor,
++ listMessages,
++ deleteMessages,
++ destroyAccount,
++ messageBody,
++ invalidateCaches,
++ ensureDomain,
++ listDomains,
++ forgetAccount: (mailboxAddress) => accountCache.delete(String(mailboxAddress || '').toLowerCase()),
++ };
++}
++
++module.exports = { createStalwartClient, StalwartError, ADDRESS_RE };
ddiff --git a/server/stalwart/code.js b/server/stalwart/code.js
nnew file mode 100644
iindex 0000000..c103d89
---- /dev/null
++++ b/server/stalwart/code.js
@@@ -0,0 +1,127 @@
++'use strict';
++
++/**
++ * The verification code out of a message.
++ *
++ * A burner exists to receive one code, so the code is the one thing the service has to find in a message it has
++ * never seen and never asked for. The extractor reads the text and the html of a message, in that order, and
++ * returns the first code it can stand behind — or nothing at all. It never guesses: a message without a code
++ * gives no code, and the caller says so.
++ *
++ * The rules, in order:
++ *
++ * 1. a labelled code — "your verification code is 482913", "code: 482913", "one-time PIN 482913";
++ * 2. a code in the subject on its own — the subject "482913" or "Your code: 482913";
++ * 3. a code standing alone in the body — a line that is nothing but the digits.
++ *
++ * A candidate must be a run of four to eight digits that does not continue into other digits (so a phone number is
++ * not a code), and a four-digit candidate that reads like a year is only accepted when it was labelled.
++ */
++
++const LABELLED = [
++ // your verification code is 482913 · your code: 482913 · code 482913
++ /(?:verification|confirmation|security|access|sign[- ]?in|login|one[- ]?time|onetime|otp|2fa|mfa|auth(?:entication)?|confirmation)\s*(?:code|pin|passcode|key|number|no\.)?\s*(?:is|are|:|-|–|—)?\s*(?:is\s+)?([0-9]{4,8})(?![0-9])/i,
++ // your one-time code
++ /(?:code|pin|passcode)\s*(?:is|:|-|–|—)?\s*([0-9]{4,8})(?![0-9])/i,
++ // 482913 is your verification code
++ /\b([0-9]{4,8})(?![0-9])\s*(?:is|are)\s+(?:your|the)\s+(?:verification|confirmation|security|access|one[- ]?time|sign[- ]?in|login)?\s*(?:code|pin|passcode)\b/i,
++ // otp 482913 · otp: 482913
++ /\botp\b\s*(?:is|:|-|–|—)?\s*([0-9]{4,8})(?![0-9])/i,
++];
++
++const ALONE_LINE = /^\s*([0-9]{4,8})\s*$/;
++const ALONE_TOKEN = /(?<![0-9])([0-9]{4,8})(?![0-9])/g;
++
++function decodeEntities(text) {
++ return String(text || '')
++ .replace(/&nbsp;/gi, ' ')
++ .replace(/&amp;/gi, '&')
++ .replace(/&lt;/gi, '<')
++ .replace(/&gt;/gi, '>')
++ .replace(/&quot;/gi, '"')
++ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n) || 32))
++ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16) || 32));
++}
++
++/** The readable text of an html body: no tags, no scripts, blocks on their own lines. */
++function htmlToText(html) {
++ return decodeEntities(String(html || '')
++ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
++ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
++ .replace(/<br\s*\/?>/gi, '\n')
++ .replace(/<\/(p|div|tr|li|h[1-6]|table|blockquote)>/gi, '\n')
++ .replace(/<[^>]+>/g, ' '))
++ .replace(/[ \t ]+/g, ' ')
++ .replace(/\n{3,}/g, '\n\n')
++ .trim();
++}
++
++function looksLikeYear(code) {
++ return code.length === 4 && Number(code) >= 1950 && Number(code) <= 2099;
++}
++
++/** A digit run that is not part of a longer number: 482913 in "order 48291312" is not a code. */
++function isWholeRun(text, index, length) {
++ const before = index > 0 ? text[index - 1] : '';
++ const after = index + length < text.length ? text[index + length] : '';
++ return !/[0-9]/.test(before) && !/[0-9]/.test(after);
++}
++
++/**
++ * The code in a message, or null.
++ *
++ * extractCode({ subject, text, html }) → { code, from } | null
++ *
++ * `from` says where the code was found: 'label', 'subject' or 'body'.
++ */
++function extractCode({ subject = '', text = '', html = '' } = {}) {
++ const body = htmlToText(html);
++ const plain = [String(text || ''), body].filter(Boolean).join('\n');
++ const subjectText = String(subject || '').trim();
++
++ // 1. a labelled code, in the subject first and then in the body.
++ for (const source of [subjectText, plain]) {
++ if (!source) continue;
++ for (const pattern of LABELLED) {
++ const m = pattern.exec(source);
++ if (!m) continue;
++ const code = m[1];
++ const at = m.index + m[0].lastIndexOf(code);
++ if (!isWholeRun(source, at, code.length)) continue;
++ return { code, from: source === subjectText ? 'subject' : 'label' };
++ }
++ }
++
++ // 2. the subject on its own: "482913" or "Your code 482913" without a label word we know.
++ if (subjectText) {
++ const alone = ALONE_LINE.exec(subjectText);
++ if (alone) return { code: alone[1], from: 'subject' };
++ const token = ALONE_TOKEN.exec(subjectText);
++ if (token && !looksLikeYear(token[1])) {
++ const at = token.index;
++ if (isWholeRun(subjectText, at, token[1].length)) return { code: token[1], from: 'subject' };
++ }
++ }
++
++ // 3. a line in the body that is nothing but the code.
++ for (const line of plain.split('\n')) {
++ const alone = ALONE_LINE.exec(line);
++ if (!alone) continue;
++ if (looksLikeYear(alone[1])) continue;
++ return { code: alone[1], from: 'body' };
++ }
++
++ // 4. the first standalone number in the body that is not a year and not part of a longer number.
++ const scan = plain.slice(0, 4000);
++ ALONE_TOKEN.lastIndex = 0;
++ let token = ALONE_TOKEN.exec(scan);
++ while (token) {
++ const code = token[1];
++ if (!looksLikeYear(code) && isWholeRun(scan, token.index, code.length)) return { code, from: 'body' };
++ token = ALONE_TOKEN.exec(scan);
++ }
++
++ return null;
++}
++
++module.exports = { extractCode, htmlToText, decodeEntities };
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..8df135f
---- /dev/null
++++ b/test/api.test.js
@@@ -0,0 +1,340 @@
++'use strict';
++/**
++ * The API of OpenVibe.Mail.
++ *
++ * The rules these tests hold: a person may do everything with their own addresses and nothing at all with somebody
++ * else's; a burner made without an account is read with its token; a message body is never in an answer and never
++ * in a log line.
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const { boot } = require('./helpers/boot');
++
++test('a person makes a mask, and the mask is theirs', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/masks', { as: alice, json: { label: 'Amazon', purpose: 'shopping' } });
++ assert.strictEqual(made.status, 201);
++ assert.match(made.body.mask.address, /^amazon\.[a-z0-9]{4}@mask\.openvibe\.email$/);
++ assert.strictEqual(made.body.mask.label, 'Amazon');
++ assert.strictEqual(made.body.mask.enabled, true);
++
++ const list = await s.get('/api/v1/masks', { as: alice });
++ assert.strictEqual(list.status, 200);
++ assert.strictEqual(list.body.masks.length, 1);
++
++ const rows = await s.db.many('SELECT envelope FROM event_outbox ORDER BY id');
++ assert.strictEqual(rows.length, 1);
++ assert.strictEqual(rows[0].envelope.event_type, 'mail.mask.created');
++});
++
++test('another person does not find the mask', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const bob = s.network.addUser('bob');
++
++ const made = await s.post('/api/v1/masks', { as: alice, json: { label: 'Amazon' } });
++ const id = made.body.mask.id;
++
++ const seen = await s.get(`/api/v1/masks/${id}`, { as: bob });
++ assert.strictEqual(seen.status, 404);
++ assert.strictEqual(seen.body.code, 'mail.not_found');
++
++ const list = await s.get('/api/v1/masks', { as: bob });
++ assert.strictEqual(list.body.masks.length, 0);
++});
++
++test('a mask without a token is not found', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const made = await s.post('/api/v1/masks', { json: { label: 'Amazon' } });
++ assert.strictEqual(made.status, 401);
++});
++
++test('turning a mask off makes the mail server forget it', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/masks', { as: alice, json: { label: 'Amazon' } });
++ const id = made.body.mask.id;
++
++ const off = await s.del(`/api/v1/masks/${id}`, { as: alice });
++ assert.strictEqual(off.status, 200);
++ assert.strictEqual(off.body.mask.enabled, false);
++ assert.ok(s.mail.calls.invalidateCaches >= 1, 'the mail server was told to forget the address');
++
++ const again = await s.del(`/api/v1/masks/${id}`, { as: alice });
++ assert.strictEqual(again.status, 404);
++});
++
++test('a person has twenty masks and no more', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ for (let i = 0; i < 20; i++) {
++ const made = await s.post('/api/v1/masks', { as: alice, json: { label: `site${i}` } });
++ assert.strictEqual(made.status, 201);
++ }
++ const over = await s.post('/api/v1/masks', { as: alice, json: { label: 'one more' } });
++ assert.strictEqual(over.status, 409);
++ assert.strictEqual(over.body.code, 'mail.mask_limit');
++});
++
++test('a burner made without an account is read with its token', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++
++ const made = await s.post('/api/v1/burners', { json: { expires: '10m' } });
++ assert.strictEqual(made.status, 201);
++ assert.strictEqual(made.body.burner.owner, null);
++ assert.match(made.body.burner.address, /^[a-z0-9]{8}@burn[12]\.openvibe\.email$/);
++ assert.ok(made.body.token, 'the token comes back once');
++ assert.ok(made.body.token.startsWith('mbt_'));
++
++ const without = await s.get(`/api/v1/burners/${made.body.burner.id}/messages`);
++ assert.strictEqual(without.status, 404);
++
++ const withToken = await s.get(`/api/v1/burners/${made.body.burner.id}/messages`, { token: made.body.token });
++ assert.strictEqual(withToken.status, 200);
++ assert.deepStrictEqual(withToken.body.messages, []);
++});
++
++test('a burner without an account lives for ten minutes', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++
++ const made = await s.post('/api/v1/burners', { json: { expires: '7d' } });
++ assert.strictEqual(made.status, 400);
++ assert.strictEqual(made.body.code, 'mail.anon_expiry');
++});
++
++test('a person makes a burner of their own, and nobody else may read it', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const bob = s.network.addUser('bob');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ assert.strictEqual(made.status, 201);
++ assert.strictEqual(made.body.burner.owner, alice.subject);
++ assert.strictEqual(made.body.token, null, 'a burner of a person needs no token');
++
++ const mine = await s.get(`/api/v1/burners/${made.body.burner.id}/messages`, { as: alice });
++ assert.strictEqual(mine.status, 200);
++
++ const theirs = await s.get(`/api/v1/burners/${made.body.burner.id}/messages`, { as: bob });
++ assert.strictEqual(theirs.status, 404);
++});
++
++test('a person has five burners alive at once', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ for (let i = 0; i < 5; i++) {
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ assert.strictEqual(made.status, 201);
++ }
++ const over = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ assert.strictEqual(over.status, 409);
++ assert.strictEqual(over.body.code, 'mail.burner_limit');
++});
++
++test('the code endpoint gives the code that arrived', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ const burner = made.body.burner;
++
++ s.mail.deliver({
++ mailboxAddress: burner.address,
++ to: burner.address,
++ subject: 'Your verification code',
++ text: 'Your verification code is 918273. It expires in 10 minutes.',
++ });
++
++ const code = await s.get(`/api/v1/burners/${burner.id}/code`, { as: alice });
++ assert.strictEqual(code.status, 200);
++ assert.strictEqual(code.body.code, '918273');
++ assert.ok(code.body.received_at);
++});
++
++test('the code endpoint says so when no code has arrived', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ const code = await s.get(`/api/v1/burners/${made.body.burner.id}/code`, { as: alice });
++ assert.strictEqual(code.status, 404);
++ assert.strictEqual(code.body.code, 'mail.no_code');
++});
++
++test('an agent gets an inbox, and the messages sent to it', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'Helper' } });
++ assert.strictEqual(made.status, 201);
++ const inbox = made.body.agent_inbox;
++ assert.match(inbox.address, /^helper\.[a-z0-9]+@openvibe\.email$/);
++
++ const again = await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'helper' } });
++ assert.strictEqual(again.body.agent_inbox.id, inbox.id, 'the same agent gets the same inbox');
++
++ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [alice.subject]);
++ s.mail.deliver({ mailboxAddress: mailbox, to: inbox.address, subject: 'A question', text: 'What is the weather?' });
++
++ const messages = await s.get(`/api/v1/agent-inboxes/${inbox.id}/messages`, { as: alice });
++ assert.strictEqual(messages.status, 200);
++ assert.strictEqual(messages.body.messages.length, 1);
++ assert.strictEqual(messages.body.messages[0].subject, 'A question');
++ assert.strictEqual(messages.body.messages[0].preview, undefined, 'a list of messages carries no preview');
++});
++
++test('another person does not find an agent inbox', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const bob = s.network.addUser('bob');
++
++ const made = await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'helper' } });
++ const seen = await s.get(`/api/v1/agent-inboxes/${made.body.agent_inbox.id}/messages`, { as: bob });
++ assert.strictEqual(seen.status, 404);
++});
++
++test('no message body is ever written to a log line', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ const burner = made.body.burner;
++ s.mail.deliver({
++ mailboxAddress: burner.address,
++ to: burner.address,
++ subject: 'Your verification code',
++ text: 'Your verification code is 918273. The secret phrase is hunter2 and nobody may read it.',
++ html: '<p>Your verification code is 918273.</p>',
++ });
++
++ await s.get(`/api/v1/burners/${burner.id}/code`, { as: alice });
++ await s.get(`/api/v1/burners/${burner.id}/messages`, { as: alice });
++
++ const logs = s.logs().join('\n');
++ assert.ok(logs.length >= 0);
++ assert.ok(!logs.includes('hunter2'), 'the body of a message is not in a log line');
++ assert.ok(!logs.includes('918273'), 'not even the code is in a log line');
++});
++
++test('a typed token is not a session', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const typed = s.network.userToken(alice, { typ: 'mail.read' });
++
++ const made = await s.post('/api/v1/masks', { bearer: typed, json: { label: 'Amazon' } });
++ assert.strictEqual(made.status, 401);
++});
++
++test('a forged Delivered-To does not move an agent message to another inbox', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const a = (await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'alpha' } })).body.agent_inbox;
++ const b = (await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'beta' } })).body.agent_inbox;
++ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [alice.subject]);
++ s.mail.deliver({
++ mailboxAddress: mailbox,
++ to: a.address,
++ subject: 'for alpha',
++ text: 'Your code is 101010',
++ headers: [
++ { name: 'To', value: b.address },
++ { name: 'Cc', value: b.address },
++ { name: 'Delivered-To', value: b.address },
++ ],
++ });
++
++ const alpha = await s.get(`/api/v1/agent-inboxes/${a.id}/messages`, { as: alice });
++ assert.strictEqual(alpha.body.messages.length, 1);
++ const beta = await s.get(`/api/v1/agent-inboxes/${b.id}/messages`, { as: alice });
++ assert.deepStrictEqual(beta.body.messages, [], 'the forged headers name beta but the server delivered to alpha');
++});
++
++test('the messages API returns metadata and never a body or HTML', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ const burner = made.body.burner;
++ s.mail.deliver({
++ mailboxAddress: burner.address,
++ to: burner.address,
++ subject: 'Your code',
++ text: 'Your verification code is 121212',
++ html: '<p>Your verification code is <b>121212</b></p>',
++ });
++
++ const list = await s.get(`/api/v1/burners/${burner.id}/messages`, { as: alice });
++ assert.strictEqual(list.status, 200);
++ assert.deepStrictEqual(Object.keys(list.body.messages[0]).sort(), ['from', 'id', 'received_at', 'subject']);
++ assert.ok(!('body' in list.body.messages[0]) && !('html' in list.body.messages[0]) && !('preview' in list.body.messages[0]));
++
++ const code = await s.get(`/api/v1/burners/${burner.id}/code`, { as: alice });
++ assert.strictEqual(code.status, 200);
++ assert.strictEqual(code.body.code, '121212');
++ assert.ok(!('body' in code.body) && !('text' in code.body) && !('html' in code.body));
++});
++
++test('the global anonymous-burner cap answers 503 with a clear code', async (t) => {
++ const s = await boot({ env: {
++ MAIL_MAX_LIVE_ANON_BURNERS: '2',
++ MAIL_BURNERS_PER_IP_HOUR: '100',
++ } });
++ t.after(() => s.close());
++
++ assert.strictEqual((await s.post('/api/v1/burners', { json: { expires: '10m' } })).status, 201);
++ assert.strictEqual((await s.post('/api/v1/burners', { json: { expires: '10m' } })).status, 201);
++ const over = await s.post('/api/v1/burners', { json: { expires: '10m' } });
++ assert.strictEqual(over.status, 503);
++ assert.strictEqual(over.body.code, 'mail.burner_capacity');
++});
++
++test('the global cap holds when two account-less burners race for the last slot', async (t) => {
++ const s = await boot({ env: {
++ MAIL_MAX_LIVE_ANON_BURNERS: '1',
++ MAIL_BURNERS_PER_IP_HOUR: '100',
++ } });
++ t.after(() => s.close());
++
++ const [first, second] = await Promise.all([
++ s.post('/api/v1/burners', { json: { expires: '10m' } }),
++ s.post('/api/v1/burners', { json: { expires: '10m' } }),
++ ]);
++ assert.deepStrictEqual([first.status, second.status].sort(), [201, 503]);
++});
++
++test('anonymous burners are limited per IP as well as globally', async (t) => {
++ const s = await boot({ env: {
++ MAIL_MAX_LIVE_ANON_BURNERS: '100',
++ MAIL_BURNERS_PER_IP_HOUR: '2',
++ } });
++ t.after(() => s.close());
++
++ assert.strictEqual((await s.post('/api/v1/burners', { json: { expires: '10m' } })).status, 201);
++ assert.strictEqual((await s.post('/api/v1/burners', { json: { expires: '10m' } })).status, 201);
++ const over = await s.post('/api/v1/burners', { json: { expires: '10m' } });
++ assert.strictEqual(over.status, 429);
++ assert.strictEqual(over.body.code, 'mail.burner_rate');
++});
ddiff --git a/test/code.test.js b/test/code.test.js
nnew file mode 100644
iindex 0000000..c61c3a2
---- /dev/null
++++ b/test/code.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * The code extractor: the one place a message body is read.
++ *
++ * A burner exists to receive a code, so the extractor has to find the code in a message it has never seen. It must
++ * also not find a code where there is none: a wrong code is worse than no code.
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const { extractCode, htmlToText } = require('../server/stalwart/code');
++
++test('a labelled code in the text', () => {
++ const found = extractCode({ subject: 'Verify your email', text: 'Your verification code is 482913. It expires in 10 minutes.' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '482913');
++});
++
++test('a labelled code in the subject', () => {
++ const found = extractCode({ subject: 'Your code: 731204', text: 'Hello.' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '731204');
++});
++
++test('a code in an html body', () => {
++ const html = '<html><body><p>Your one-time code is <b>918273</b></p><script>var x=1;</script></body></html>';
++ const found = extractCode({ subject: 'Sign in', html });
++ assert.ok(found);
++ assert.strictEqual(found.code, '918273');
++});
++
++test('a code standing alone on a line', () => {
++ const found = extractCode({ subject: 'Hello', text: 'Hi there.\n\n551209\n\nThanks.' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '551209');
++});
++
++test('the subject alone, when it is only the code', () => {
++ const found = extractCode({ subject: '482913', text: '' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '482913');
++});
++
++test('no code in a message without one', () => {
++ const found = extractCode({ subject: 'Your receipt', text: 'Thank you for your order of 3 items, total 12.50.' });
++ assert.strictEqual(found, null);
++});
++
++test('a year is not a code', () => {
++ assert.strictEqual(extractCode({ subject: 'Hello', text: 'Call us in 2026 for support.' }), null);
++});
++
++test('a year is a code when it is labelled', () => {
++ const found = extractCode({ subject: 'Your code', text: 'Your verification code is 2026.' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '2026');
++});
++
++test('a longer number is not a code', () => {
++ assert.strictEqual(extractCode({ subject: 'Order', text: 'Your order number 48291312345 has shipped.' }), null);
++});
++
++test('a phone number is not a code', () => {
++ assert.strictEqual(extractCode({ subject: 'Call us', text: 'Call 5551234567 for support.' }), null);
++});
++
++test('a labelled code wins over a bare number', () => {
++ const found = extractCode({ subject: 'Sign in', text: 'Order 123456 shipped. Your verification code is 482913.' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '482913');
++});
++
++test('html becomes readable text', () => {
++ const text = htmlToText('<div>Hello<br>world</div><style>p{color:red}</style>');
++ assert.ok(text.includes('Hello'));
++ assert.ok(text.includes('world'));
++ assert.ok(!text.includes('color:red'));
++});
++
++test('html entities are decoded', () => {
++ const found = extractCode({ subject: '', html: '<p>Your code is&nbsp;<b>778899</b></p>' });
++ assert.ok(found);
++ assert.strictEqual(found.code, '778899');
++});
ddiff --git a/test/directory.test.js b/test/directory.test.js
nnew file mode 100644
iindex 0000000..b5c66bf
---- /dev/null
++++ b/test/directory.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * The directory view is the whole privacy model: one row means deliver, no row means bounce. These tests hold the
++ * view to that without needing Stalwart.
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const { boot } = require('./helpers/boot');
++
++const rowFor = (s, address) => s.db.value('SELECT count(*)::int FROM mail_directory_accounts WHERE address = $1', [address]);
++const nameFor = (s, address) => s.db.value('SELECT name FROM mail_directory_accounts WHERE address = $1', [address]);
++
++test('a mask resolves while it is on, and not after it is turned off', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const mask = (await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } })).body.mask;
++ assert.strictEqual(await rowFor(s, mask.address), 1);
++ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [alice.subject]);
++ assert.strictEqual(await nameFor(s, mask.address), mailbox);
++
++ await s.del(`/api/v1/masks/${mask.id}`, { as: alice });
++ assert.strictEqual(await rowFor(s, mask.address), 0);
++});
++
++test('a burner resolves to its own account until it expires, and the person mailbox also resolves', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const burner = (await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
++ assert.strictEqual(await rowFor(s, burner.address), 1);
++ assert.strictEqual(await nameFor(s, burner.address), burner.address, 'the burner is its own account');
++ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [alice.subject]);
++ assert.strictEqual(await rowFor(s, mailbox), 1, 'the account primary address resolves');
++
++ await s.db.exec("UPDATE mail_burners SET expires_at = now() - interval '1 second' WHERE id = $1", [burner.id]);
++ assert.strictEqual(await rowFor(s, burner.address), 0);
++});
++
++test('an account-less burner is its own account, not a shared pool', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++
++ const made = await s.post('/api/v1/burners', { json: { expires: '10m' } });
++ const burner = made.body.burner;
++ assert.strictEqual(await rowFor(s, burner.address), 1);
++ assert.strictEqual(await nameFor(s, burner.address), burner.address);
++ assert.strictEqual(await s.db.value("SELECT count(*)::int FROM mail_mailboxes WHERE owner_subject = 'service:burner-pool'"), 0);
++ assert.strictEqual(await s.db.value("SELECT count(*)::int FROM mail_directory_accounts WHERE name = $1 AND address <> $1", [burner.address]), 0);
++});
++
++test('two burners are two accounts', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++
++ const first = (await s.post('/api/v1/burners', { json: { expires: '10m' } })).body.burner;
++ const second = (await s.post('/api/v1/burners', { json: { expires: '10m' } })).body.burner;
++ assert.notStrictEqual(await nameFor(s, first.address), await nameFor(s, second.address));
++ assert.strictEqual(await nameFor(s, first.address), first.address);
++ assert.strictEqual(await nameFor(s, second.address), second.address);
++});
++
++test('an agent inbox resolves to its owner mailbox', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const inbox = (await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'helper' } })).body.agent_inbox;
++ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [alice.subject]);
++ assert.strictEqual(await rowFor(s, inbox.address), 1);
++ assert.strictEqual(await nameFor(s, inbox.address), mailbox);
++});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..c172bc9
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,99 @@
++'use strict';
++
++/**
++ * Boots OpenVibe.Mail on a temporary database with a stand-in for Network and a stand-in for the mail server,
++ * captures the log lines, and returns a small HTTP client.
++ *
++ * const t = await boot();
++ * t.post('/api/v1/masks', { json: { label: 'amazon' }, as: alice })
++ * t.get('/api/v1/burners/:id/code', { as: alice })
++ * t.logs() the lines the service wrote
++ * t.mail.deliver(...) a message arrives
++ */
++const http = require('http');
++const { startNetwork } = require('./network');
++const { createFakeStalwart } = require('./stalwart');
++const { testDb } = require('./db');
++
++async function boot(opts = {}) {
++ const network = await startNetwork();
++ const mail = opts.stalwart || createFakeStalwart();
++ const captured = [];
++
++ const log = {
++ log: (...a) => captured.push(a.map(String).join(' ')),
++ info: (...a) => captured.push(a.map(String).join(' ')),
++ warn: (...a) => captured.push(a.map(String).join(' ')),
++ error: (...a) => captured.push(a.map(String).join(' ')),
++ };
++
++ const configLib = require('../../server/config');
++ const config = configLib.load({
++ NODE_ENV: 'test',
++ PORT: '0',
++ BASE_URL: 'https://openvibe.email',
++ TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url,
++ OV_NETWORK_INTERNAL_URL: network.url,
++ OV_NETWORK_ISSUER: network.url,
++ MAIL_BASE_DOMAIN: 'openvibe.email',
++ MAIL_MASK_DOMAIN: 'mask.openvibe.email',
++ BURNER_DOMAINS: 'burn1.openvibe.email,burn2.openvibe.email',
++ MAIL_API_RATE_LIMIT_PER_MIN: '100000',
++ MAIL_MAX_MASKS: '20',
++ MAIL_MAX_LIVE_BURNERS: '5',
++ MAIL_BURNERS_PER_IP_HOUR: '10',
++ MAIL_ANON_BURNER_TTL_MINUTES: '10',
++ ...(opts.env || {}),
++ });
++
++ const { createStore } = require('../../server/db');
++ const store = createStore((await testDb()).db, { now: opts.now });
++
++ const { createApp } = require('../../server/app');
++ const built = await createApp({ config, store, stalwart: mail, log });
++ await built.ctx.keys.ensure();
++
++ const server = await new Promise((resolve) => {
++ const s = http.createServer(built.app);
++ s.listen(0, '127.0.0.1', () => resolve(s));
++ });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ async function call(method, p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.authorization = `Bearer ${network.userToken(o.as)}`;
++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
++ if (o.token) headers['x-mail-burner-token'] = o.token;
++ let body;
++ if (o.json !== undefined) {
++ body = JSON.stringify(o.json);
++ headers['content-type'] = 'application/json';
++ }
++ const res = await fetch(`${base}${p}`, { method, headers, body });
++ const text = await res.text();
++ let parsed = null;
++ try { parsed = JSON.parse(text); } catch { /* not json */ }
++ return { status: res.status, body: parsed, text, headers: res.headers };
++ }
++
++ return {
++ network,
++ mail,
++ store,
++ config,
++ db: store.db,
++ logs: () => captured.slice(),
++ clearLogs: () => { captured.length = 0; },
++ get: (p, o) => call('GET', p, o),
++ post: (p, o) => call('POST', p, o),
++ del: (p, o) => call('DELETE', p, o),
++ close: async () => {
++ await new Promise((r) => server.close(r));
++ await network.close();
++ await store.close();
++ },
++ };
++}
++
++module.exports = { boot };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..132ac6a
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,13 @@
++'use strict';
++
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * MAIL_TEST_STORE=pg (npm run test:pg) the PostgreSQL container, with roles and a schema of this run's own.
++ */
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++const testDb = ({ store = process.env.MAIL_TEST_STORE || 'pglite', max = 4 } = {}) =>
++ createTestDb({ migrations: MIGRATIONS, store, service: 'mail', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
nnew file mode 100644
iindex 0000000..c50d633
---- /dev/null
++++ b/test/helpers/network.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++/**
++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair: the JWKS document and the tokens of
++ * the people who call the service. Nothing else of Network is needed by M1.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const st = { users: new Map(), requests: [], down: false };
++ let issuer = null;
++ let nextUserId = 1;
++
++ function addUser(username, { role = 'user' } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, role };
++ st.users.set(u.id, u);
++ return u;
++ }
++
++ function userToken(u, { ttl = 3600, typ, purpose, aud = 'openvibe.network' } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, role: u.role,
++ aud: [aud], iat: now, exp: now + ttl,
++ };
++ if (typ) claims.typ = typ;
++ if (purpose) claims.purpose = purpose;
++ return signJwt(claims, privatePem);
++ }
++
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ st.requests.push({ method: req.method, path: req.url, body: raw.toString('utf8') });
++ const json = (status, obj) => {
++ res.writeHead(status, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(obj));
++ };
++ const url = new URL(req.url, 'http://x');
++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (st.down) return json(503, { error: 'network is restarting' });
++ return json(404, { error: 'not found' });
++ });
++ });
++
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ const url = `http://127.0.0.1:${server.address().port}`;
++ issuer = url;
++
++ return {
++ url,
++ publicPem,
++ state: st,
++ addUser,
++ userToken,
++ signJwt: (claims) => signJwt(claims, privatePem),
++ setDown(v) { st.down = v; },
++ close: () => new Promise((r) => server.close(r)),
++ };
++}
++
++module.exports = { startNetwork, signJwt };
ddiff --git a/test/helpers/stalwart.js b/test/helpers/stalwart.js
nnew file mode 100644
iindex 0000000..b80df5a
---- /dev/null
++++ b/test/helpers/stalwart.js
@@@ -0,0 +1,114 @@
++'use strict';
++
++/**
++ * A stand-in for the mail server, in memory.
++ *
++ * It does what the service asks of Stalwart: it keeps a mailbox per account, it hands back a mailbox's messages,
++ * it gives the body of one message, and it forgets a burner's account when the burner expires. The bodies it keeps
++ * are the bodies of the test — they are never written to a log.
++ */
++function createFakeStalwart() {
++ const mailboxes = new Map(); // account address → messages
++ const calls = { invalidateCaches: 0, listMessages: 0, deleteMessages: 0, destroyAccount: 0, messageBody: 0 };
++ let counter = 0;
++
++ function mailbox(address) {
++ const key = String(address || '').toLowerCase();
++ if (!mailboxes.has(key)) mailboxes.set(key, []);
++ return mailboxes.get(key);
++ }
++
++ return {
++ calls,
++ mailboxes,
++
++ /** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */
++ deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [] }) {
++ counter += 1;
++ const message = {
++ id: `m${counter}`,
++ from: [{ email: from, name: null }],
++ to: [{ email: String(to).toLowerCase(), name: null }],
++ subject,
++ receivedAt: receivedAt || new Date().toISOString(),
++ text,
++ html,
++ headers: [
++ { name: 'Delivered-To', value: ` ${String(to).toLowerCase()}` },
++ ...headers,
++ ],
++ };
++ mailbox(mailboxAddress).push(message);
++ return message;
++ },
++
++ async accountIdFor(address) {
++ return String(address || '').toLowerCase();
++ },
++
++ async listMessages({ mailboxAddress, recipient = null, limit = 20 }) {
++ calls.listMessages += 1;
++ const all = mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [];
++ const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
++ const stamped = (message) => {
++ const first = (message.headers || []).find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to');
++ return first ? String(first.value || '').trim().toLowerCase() : null;
++ };
++ // This fake stands in for the Stalwart client, so it applies the same rule as the real one: a shared
++ // mailbox is filtered by the first (server-stamped) Delivered-To, never by sender-written headers.
++ return all
++ .filter((m) => !wanted || stamped(m) === wanted)
++ .slice()
++ .sort((a, b) => String(b.receivedAt).localeCompare(String(a.receivedAt)))
++ .slice(0, limit)
++ .map((m) => ({
++ id: m.id,
++ from: m.from.map((a) => a.email),
++ to: m.to.map((a) => a.email),
++ subject: m.subject,
++ receivedAt: m.receivedAt,
++ spam: false,
++ headers: m.headers,
++ }));
++ },
++
++ async messageBody({ mailboxAddress, id }) {
++ calls.messageBody += 1;
++ const all = mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [];
++ const message = all.find((m) => m.id === id);
++ if (!message) return { text: '', html: '' };
++ return { text: message.text, html: message.html };
++ },
++
++ async deleteMessages({ mailboxAddress, ids }) {
++ calls.deleteMessages += 1;
++ const key = String(mailboxAddress || '').toLowerCase();
++ const all = mailboxes.get(key) || [];
++ const wanted = new Set(ids || []);
++ const kept = all.filter((m) => !wanted.has(m.id));
++ mailboxes.set(key, kept);
++ return all.length - kept.length;
++ },
++
++ async destroyAccount({ mailboxAddress }) {
++ calls.destroyAccount += 1;
++ return mailboxes.delete(String(mailboxAddress || '').toLowerCase());
++ },
++
++ async invalidateCaches() {
++ calls.invalidateCaches += 1;
++ },
++
++ async ensureDomain(name) {
++ return false;
++ },
++
++ async listDomains() {
++ return [];
++ },
++
++ forgetAccount() {},
++ };
++}
++
++module.exports = { createFakeStalwart };
ddiff --git a/test/purge.test.js b/test/purge.test.js
nnew file mode 100644
iindex 0000000..79534a2
---- /dev/null
++++ b/test/purge.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * The purge job: a burner's mail goes when the burner expires, and only then — and a mail server that is down
++ * does not lose the work, it only delays it to the next run.
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const { boot } = require('./helpers/boot');
++const { runDue } = require('../server/jobs/purge');
++
++test('an expired burner loses its mail and its row, and the directory cache is cleared', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ const burner = made.body.burner;
++ s.mail.deliver({ mailboxAddress: burner.address, to: burner.address, subject: 'code', text: 'Your code is 445566' });
++
++ await s.db.exec("UPDATE mail_burners SET expires_at = now() - interval '1 minute' WHERE id = $1", [burner.id]);
++ const before = s.mail.calls.invalidateCaches;
++ const report = await runDue({ db: s.db, stalwart: s.mail, events: null, now: () => Date.now() });
++
++ assert.strictEqual(report.expired, 1);
++ assert.strictEqual(report.messagesDeleted, 1);
++ assert.ok(s.mail.calls.invalidateCaches > before, 'the mail server forgot the expired address');
++ assert.ok(s.mail.calls.destroyAccount >= 1, 'the burner account was destroyed');
++ assert.strictEqual(await s.db.value('SELECT count(*)::int FROM mail_burners WHERE id = $1', [burner.id]), 0);
++ assert.strictEqual(s.mail.mailboxes.has(burner.address.toLowerCase()), false);
++});
++
++test('a failed delete keeps the burner, so the next run deletes the mail', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++
++ const made = await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ await s.db.exec("UPDATE mail_burners SET expires_at = now() - interval '1 minute' WHERE id = $1", [made.body.burner.id]);
++
++ const broken = {
++ ...s.mail,
++ async listMessages() { throw new Error('the mail server is down'); },
++ };
++ const before = broken.calls.invalidateCaches;
++ const report = await runDue({ db: s.db, stalwart: broken, events: null, now: () => Date.now() });
++
++ assert.strictEqual(report.expired, 0);
++ assert.ok(broken.calls.invalidateCaches > before, 'the expired address still leaves the cache even though its mail was not deleted');
++ assert.strictEqual(await s.db.value('SELECT count(*)::int FROM mail_burners WHERE id = $1', [made.body.burner.id]), 1);
++});
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..4bed154
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,19 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temporary PGlite databases, an
++ * in-process stand-in for OpenVibe.Network and an in-memory mail server; none needs the network.
++ *
++ * npm test # everything
++ * npm test -- api # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ */
++'use strict';
++const { run } = require('openvibe-shared/test-runner');
++
++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
++ if (process.env.VERBOSE) {
++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
++ console.log(`\n${r.summary}`);
++ }
++ process.exit(r.exitCode);
++}, (err) => { console.error(err); process.exit(1); });
ddiff --git a/test/stalwart/helpers/pg.js b/test/stalwart/helpers/pg.js
nnew file mode 100644
iindex 0000000..234a58a
---- /dev/null
++++ b/test/stalwart/helpers/pg.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/**
++ * The PostgreSQL the integration test owns.
++ *
++ * Stalwart keeps its own tables in the same database as the service (the directory view is how they meet), so the
++ * test uses a database of its own — `ov_mail_test` by default — and drops its public schema before it starts. It
++ * never touches the database named by DATABASE_URL in a shell that points at a real service.
++ */
++const { Client } = require('pg');
++
++function settings(env = process.env) {
++ return {
++ host: env.STALWART_DB_HOST || '127.0.0.1',
++ port: Number(env.STALWART_DB_PORT || 5432),
++ user: env.STALWART_DB_USER || 'ov',
++ password: env.STALWART_DB_PASSWORD || '',
++ database: env.STALWART_TEST_DB || 'ov_mail_test',
++ ssl: /^(1|true|yes)$/i.test(env.STALWART_DB_SSL || '') ? { rejectUnauthorized: false } : false,
++ };
++}
++
++function urlFor(db, env = process.env) {
++ const s = settings(env);
++ const auth = `${encodeURIComponent(s.user)}:${encodeURIComponent(s.password)}`;
++ return `postgres://${auth}@${s.host}:${s.port}/${encodeURIComponent(db)}`;
++}
++
++async function withClient(db, fn, env = process.env) {
++ const s = settings(env);
++ const client = new Client({ ...s, database: db });
++ await client.connect();
++ try { return await fn(client); } finally { await client.end(); }
++}
++
++async function ensureDatabase(env = process.env) {
++ const s = settings(env);
++ if (!/^[a-z_][a-z0-9_]*$/.test(s.database)) throw new Error('STALWART_TEST_DB must be a plain database name');
++ await withClient('postgres', async (client) => {
++ const found = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [s.database]);
++ if (found.rowCount) return;
++ await client.query(`CREATE DATABASE "${s.database}"`);
++ }, env);
++ return s.database;
++}
++
++async function resetSchema(env = process.env) {
++ const s = settings(env);
++ await withClient(s.database, async (client) => {
++ await client.query('DROP SCHEMA IF EXISTS public CASCADE');
++ await client.query('CREATE SCHEMA public');
++ await client.query('GRANT ALL ON SCHEMA public TO current_user');
++ }, env);
++}
++
++module.exports = { settings, urlFor, ensureDatabase, resetSchema, withClient };
ddiff --git a/test/stalwart/helpers/s3.js b/test/stalwart/helpers/s3.js
nnew file mode 100644
iindex 0000000..b645663
---- /dev/null
++++ b/test/stalwart/helpers/s3.js
@@@ -0,0 +1,36 @@
++'use strict';
++
++/**
++ * The S3-compatible target of the integration test (MinIO in CI). AWS Signature V4 is left to curl, which every
++ * runner and workstation here has; the test only needs to create the bucket and count the objects Stalwart wrote.
++ */
++const { execFileSync } = require('child_process');
++
++function curl(args) {
++ return execFileSync('curl', ['-sS', '--max-time', '20', '--aws-sigv4', `aws:amz:${process.env.STALWART_BLOB_REGION || 'us-east-1'}:s3`, '--user', `${process.env.STALWART_BLOB_ACCESS_KEY_ID}:${process.env.STALWART_BLOB_SECRET_KEY}`, ...args], { encoding: 'utf8' });
++}
++
++function endpoint() {
++ return String(process.env.STALWART_BLOB_ENDPOINT || '').replace(/\/+$/, '');
++}
++
++function bucket() {
++ return process.env.STALWART_BLOB_BUCKET;
++}
++
++function ensureBucket() {
++ if (!endpoint() || !bucket()) throw new Error('STALWART_BLOB_ENDPOINT and STALWART_BLOB_BUCKET are needed');
++ const status = execFileSync('curl', ['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '20', '--aws-sigv4', `aws:amz:${process.env.STALWART_BLOB_REGION || 'us-east-1'}:s3`, '--user', `${process.env.STALWART_BLOB_ACCESS_KEY_ID}:${process.env.STALWART_BLOB_SECRET_KEY}`, '-X', 'PUT', `${endpoint()}/${bucket()}`], { encoding: 'utf8' });
++ // 200 created, 409 already there. Anything else is the environment, not the code, but it must be loud.
++ if (!['200', '409'].includes(String(status).trim())) throw new Error(`could not create the test bucket (HTTP ${status})`);
++}
++
++function listKeys() {
++ if (!endpoint() || !bucket()) return [];
++ const xml = curl([`${endpoint()}/${bucket()}?list-type=2`]);
++ const keys = [];
++ for (const m of String(xml).matchAll(/<Key>([^<]+)<\/Key>/g)) keys.push(m[1]);
++ return keys;
++}
++
++module.exports = { ensureBucket, listKeys };
ddiff --git a/test/stalwart/helpers/smtp.js b/test/stalwart/helpers/smtp.js
nnew file mode 100644
iindex 0000000..0390434
---- /dev/null
++++ b/test/stalwart/helpers/smtp.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++/**
++ * A tiny SMTP client for the integration test: enough of RFC 5321 to deliver messages and to read the code the
++ * server answers with. It is deliberately not a mail library — a test that used the service's own stack to deliver
++ * the mail could hide a bug in that stack.
++ */
++const net = require('net');
++
++function readReply(socket, timeoutMs = 15_000) {
++ return new Promise((resolve, reject) => {
++ let buffer = '';
++ const timer = setTimeout(() => cleanup(new Error('the SMTP server did not answer')), timeoutMs);
++ const onData = (chunk) => {
++ buffer += chunk.toString('utf8');
++ const lines = buffer.split('\r\n').filter(Boolean);
++ const last = lines[lines.length - 1];
++ if (!last || !/^\d{3}(?: |$)/.test(last)) return;
++ const code = Number(last.slice(0, 3));
++ cleanup(null, { code, lines: lines.slice() });
++ };
++ const onError = (err) => cleanup(err);
++ const cleanup = (err, value) => {
++ clearTimeout(timer);
++ socket.off('data', onData);
++ socket.off('error', onError);
++ if (err) reject(err); else resolve(value);
++ };
++ socket.on('data', onData);
++ socket.on('error', onError);
++ });
++}
++
++function write(socket, line) {
++ socket.write(`${line}\r\n`);
++}
++
++function message(opts, index) {
++ const {
++ from = '[email protected]', to, subject = 'Hello', text = '', html = null,
++ toHeader = null, cc = null, extraHeaders = [],
++ } = opts;
++ const topic = typeof subject === 'function' ? subject(index) : subject;
++ const headers = [
++ `From: ${from}`,
++ `To: ${toHeader || to}`,
++ `Subject: ${topic}`,
++ `Message-ID: <${Date.now()}.${index}.${Math.random().toString(16).slice(2)}@example.net>`,
++ 'MIME-Version: 1.0',
++ ];
++ if (cc) headers.push(`Cc: ${cc}`);
++ for (const header of extraHeaders) headers.push(header);
++ let body;
++ if (html) {
++ headers.push('Content-Type: text/html; charset=utf-8');
++ body = html;
++ } else {
++ headers.push('Content-Type: text/plain; charset=utf-8');
++ body = text;
++ }
++ return `${headers.join('\r\n')}\r\n\r\n${body}\r\n.\r\n`;
++}
++
++async function deliverOn(socket, opts, index) {
++ const { from = '[email protected]', to } = opts;
++ write(socket, `MAIL FROM:<${from}>`);
++ const sender = await readReply(socket);
++ if (sender.code >= 400) throw Object.assign(new Error(`MAIL FROM refused: ${sender.code}`), { smtp: sender });
++ write(socket, `RCPT TO:<${to}>`);
++ const recipient = await readReply(socket);
++ if (recipient.code >= 400) throw Object.assign(new Error(`RCPT TO refused: ${recipient.code}`), { smtp: recipient, refused: true });
++ write(socket, 'DATA');
++ const data = await readReply(socket);
++ if (data.code >= 400) throw Object.assign(new Error(`DATA refused: ${data.code}`), { smtp: data, refused: true });
++ socket.write(message(opts, index));
++ const accepted = await readReply(socket);
++ if (accepted.code >= 400) throw Object.assign(new Error(`the message was refused: ${accepted.code}`), { smtp: accepted, refused: true });
++ return accepted;
++}
++
++async function withSession(opts, fn) {
++ const host = opts.host || '127.0.0.1';
++ const socket = net.createConnection({ host, port: opts.port });
++ socket.setTimeout(opts.timeoutMs || 20_000);
++ try {
++ await readReply(socket);
++ write(socket, 'EHLO test.openvibe.invalid');
++ await readReply(socket);
++ const result = await fn(socket);
++ write(socket, 'QUIT');
++ await readReply(socket).catch(() => null);
++ return result;
++ } finally {
++ try { socket.destroy(); } catch { /* already closed */ }
++ }
++}
++
++/** Send one message. Rejects with `refused` when the envelope recipient or the data is refused. */
++async function send(opts) {
++ return withSession(opts, (socket) => deliverOn(socket, opts, 0));
++}
++
++/** Send `count` messages over one connection: enough to fill a mailbox without 300 TCP handshakes. */
++async function sendMany(opts) {
++ const count = Number(opts.count) || 1;
++ return withSession(opts, async (socket) => {
++ const accepted = [];
++ for (let i = 0; i < count; i++) accepted.push(await deliverOn(socket, opts, i));
++ return accepted;
++ });
++}
++
++module.exports = { send, sendMany, readReply };
ddiff --git a/test/stalwart/helpers/stalwart.js b/test/stalwart/helpers/stalwart.js
nnew file mode 100644
iindex 0000000..ab29b33
---- /dev/null
++++ b/test/stalwart/helpers/stalwart.js
@@@ -0,0 +1,191 @@
++'use strict';
++
++/**
++ * Start the pinned Stalwart binary against the test PostgreSQL and MinIO, render and apply the repository's own
++ * configuration, and wait until it answers.
++ *
++ * The binary is downloaded and checksum-verified by CI (`.github/workflows/ci.yml`); locally set STALWART_BIN to
++ * the binary you fetched. Nothing here writes a secret to disk: the rendered config.json names an environment
++ * variable for the database password, and apply.js reads the S3 keys from the environment.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawn, execFileSync } = require('child_process');
++const { createClient, apply } = require('../../../deploy/stalwart/apply');
++const { settings } = require('./pg');
++
++const ROOT = path.join(__dirname, '..', '..', '..');
++
++function testEnv(overrides = {}) {
++ const pg = settings();
++ return {
++ ...process.env,
++ NODE_ENV: 'test',
++ MAIL_BASE_DOMAIN: 'openvibe.test',
++ MAIL_MASK_DOMAIN: 'mask.openvibe.test',
++ BURNER_DOMAINS: 'burn1.openvibe.test,burn2.openvibe.test',
++ STALWART_DEFAULT_HOSTNAME: 'mail.openvibe.test',
++ STALWART_DB_HOST: pg.host,
++ STALWART_DB_PORT: String(pg.port),
++ STALWART_DB_NAME: pg.database,
++ STALWART_DB_USER: pg.user,
++ STALWART_DB_PASSWORD: pg.password,
++ STALWART_SMTP_BIND: '127.0.0.1',
++ STALWART_SMTP_PORT: '2525',
++ STALWART_HTTP_BIND: '127.0.0.1',
++ STALWART_HTTP_PORT: '18080',
++ STALWART_IMAP_BIND: '127.0.0.1',
++ STALWART_IMAP_PORT: '1143',
++ STALWART_URL: 'http://127.0.0.1:18080',
++ STALWART_ADMIN_USER: 'admin',
++ STALWART_ADMIN_PASSWORD: 'ov-test-admin-password',
++ ...overrides,
++ };
++}
++
++function binaryPath(env = process.env) {
++ return env.STALWART_BIN || path.join(ROOT, '.cache', 'stalwart', 'stalwart');
++}
++
++function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
++
++async function waitFor(check, { timeoutMs = 40_000, everyMs = 250, what = 'the mail server' } = {}) {
++ const deadline = Date.now() + timeoutMs;
++ let last = null;
++ while (Date.now() < deadline) {
++ try {
++ const value = await check();
++ if (value) return value;
++ } catch (err) { last = err; }
++ await sleep(everyMs);
++ }
++ throw new Error(`${what} did not answer in ${timeoutMs}ms${last ? `: ${last.message}` : ''}`);
++}
++
++/**
++ * The acceptance test sends 300 messages to one burner in a burst. Production keeps Stalwart's inbound throttles
++ * (5/second per IP, 25/hour per sender-domain/recipient); only this harness turns them off, after the plan is
++ * applied and the settings are reloaded.
++ */
++async function disableInboundThrottles(client) {
++ const q = await client.one('x:MtaInboundThrottle/query', { filter: {}, limit: 20 }, 'tq');
++ if (!(q.ids || []).length) return;
++ const update = {};
++ for (const id of q.ids) update[id] = { enable: false };
++ await client.one('x:MtaInboundThrottle/set', { update }, 'tt');
++ await client.one('x:Action/set', { create: { reload: { '@type': 'ReloadSettings' } } }, 'trs');
++}
++
++async function start({ env = testEnv(), binary = null } = {}) {
++ const bin = binary || binaryPath(env);
++ if (!fs.existsSync(bin)) throw new Error(`STALWART_BIN not found: ${bin} (download the pinned release first)`);
++ for (const name of ['STALWART_BLOB_ENDPOINT', 'STALWART_BLOB_BUCKET', 'STALWART_BLOB_ACCESS_KEY_ID', 'STALWART_BLOB_SECRET_KEY']) {
++ if (!env[name]) throw new Error(`${name} is not set: the integration test needs the S3/MinIO target`);
++ }
++
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mail-stalwart-'));
++ execFileSync(process.execPath, [path.join(ROOT, 'deploy', 'stalwart', 'render.js'), '--out', dir], { env, stdio: 'pipe' });
++ const plan = JSON.parse(fs.readFileSync(path.join(dir, 'plan.json'), 'utf8'));
++
++ const logs = [];
++ const url = env.STALWART_URL;
++ const auth = 'Basic ' + Buffer.from(`${env.STALWART_ADMIN_USER}:${env.STALWART_ADMIN_PASSWORD}`).toString('base64');
++ const adminEnv = {
++ ...env,
++ STALWART_RECOVERY_ADMIN: `${env.STALWART_ADMIN_USER}:${env.STALWART_ADMIN_PASSWORD}`,
++ };
++
++ function launch(extra) {
++ const state = { child: null, exited: null };
++ state.child = spawn(bin, ['--config', path.join(dir, 'config.json')], {
++ env: { ...adminEnv, ...extra },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ const keep = (chunk) => {
++ logs.push(chunk.toString('utf8'));
++ if (logs.length > 400) logs.splice(0, logs.length - 400);
++ };
++ state.child.stdout.on('data', keep);
++ state.child.stderr.on('data', keep);
++ state.child.on('exit', (code, signal) => { state.exited = { code, signal }; });
++ return state;
++ }
++
++ async function waitReady(state, what) {
++ await waitFor(async () => {
++ if (state.exited) throw new Error(`Stalwart exited (${state.exited.code || state.exited.signal})`);
++ const res = await fetch(`${url}/jmap`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Authorization: auth },
++ body: JSON.stringify({ using: ['urn:ietf:params:jmap:core'], methodCalls: [['Core/echo', {}, '0']] }),
++ signal: AbortSignal.timeout(3000),
++ });
++ return res.ok;
++ }, { timeoutMs: 60_000, what });
++ }
++
++ async function stopState(state) {
++ if (state.exited) return;
++ state.child.kill('SIGTERM');
++ await Promise.race([
++ new Promise((resolve) => state.child.once('exit', resolve)),
++ sleep(8000).then(() => { try { state.child.kill('SIGKILL'); } catch { /* gone */ } }),
++ ]);
++ }
++
++ // First start is recovery mode: Stalwart has just created its schema and would otherwise try to bind the
++ // default listeners (25, 443, 8080, …) before the plan replaces them — which fails on a workstation where
++ // another process owns one of those ports, and is wrong in CI too. Recovery mode binds one HTTP port, the
++ // plan is applied, then the real listeners start.
++ const recovery = launch({
++ STALWART_RECOVERY_MODE: '1',
++ STALWART_RECOVERY_MODE_PORT: String(env.STALWART_HTTP_PORT),
++ });
++ try {
++ await waitReady(recovery, 'Stalwart JMAP (recovery)');
++ await apply(plan, createClient({
++ url,
++ user: env.STALWART_ADMIN_USER,
++ password: env.STALWART_ADMIN_PASSWORD,
++ }), env);
++ } catch (err) {
++ await stopState(recovery);
++ throw new Error(`${err.message}\n--- stalwart log ---\n${logs.join('').slice(-6000)}`);
++ }
++ await stopState(recovery);
++
++ const childState = launch({});
++ try {
++ await waitReady(childState, 'Stalwart JMAP');
++ // The normal start has now written the built-in roles, so the Authentication defaults can be set. This is
++ // the harness's second pass; an installed server runs apply once against an already-started Stalwart.
++ const client = createClient({
++ url,
++ user: env.STALWART_ADMIN_USER,
++ password: env.STALWART_ADMIN_PASSWORD,
++ });
++ await apply(plan, client, env);
++ await disableInboundThrottles(client);
++ } catch (err) {
++ await stopState(childState);
++ throw new Error(`${err.message}\n--- stalwart log ---\n${logs.join('').slice(-6000)}`);
++ }
++
++ return {
++ child: childState.child,
++ dir,
++ logs,
++ url,
++ smtpPort: Number(env.STALWART_SMTP_PORT),
++ httpPort: Number(env.STALWART_HTTP_PORT),
++ imapPort: Number(env.STALWART_IMAP_PORT),
++ env,
++ async stop() {
++ await stopState(childState);
++ fs.rmSync(dir, { recursive: true, force: true });
++ },
++ };
++}
++
++module.exports = { start, testEnv, waitFor, binaryPath, sleep };
ddiff --git a/test/stalwart/integration.test.js b/test/stalwart/integration.test.js
nnew file mode 100644
iindex 0000000..c09625b
---- /dev/null
++++ b/test/stalwart/integration.test.js
@@@ -0,0 +1,349 @@
++'use strict';
++
++/**
++ * The M1 acceptance test against the real Stalwart binary, PostgreSQL and MinIO.
++ *
++ * It is the one place the directory views meet the server that reads them:
++ * * SMTP to a burner lands, and /code returns the code;
++ * * after expiry the burner's mail is deleted and the address bounces;
++ * * mail to a disabled mask bounces;
++ * * somebody else gets 404 for a mask, a burner and an agent inbox;
++ * * no message body (and no code) is written to the service's log.
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const http = require('http');
++
++const { start, testEnv, waitFor, sleep } = require('./helpers/stalwart');
++const { ensureDatabase, resetSchema, urlFor } = require('./helpers/pg');
++const { ensureBucket, listKeys } = require('./helpers/s3');
++const { send, sendMany } = require('./helpers/smtp');
++const { startNetwork } = require('../helpers/network');
++
++const env = testEnv();
++Object.assign(process.env, env);
++
++let stalwart;
++let network;
++let store;
++let server;
++let base;
++let logs;
++let built;
++
++const configLib = require('../../server/config');
++const { openDb, createStore } = require('../../server/db');
++const { createApp } = require('../../server/app');
++const { createStalwartClient } = require('../../server/stalwart/client');
++const { runDue } = require('../../server/jobs/purge');
++
++function captureLog() {
++ const lines = [];
++ const push = (...a) => lines.push(a.map((x) => String(x)).join(' '));
++ return { lines, log: { log: push, info: push, warn: push, error: push } };
++}
++
++async function call(method, p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.authorization = `Bearer ${network.userToken(o.as)}`;
++ if (o.token) headers['x-mail-burner-token'] = o.token;
++ let body;
++ if (o.json !== undefined) {
++ body = JSON.stringify(o.json);
++ headers['content-type'] = 'application/json';
++ }
++ const res = await fetch(`${base}${p}`, { method, headers, body });
++ const text = await res.text();
++ let parsed = null;
++ try { parsed = JSON.parse(text); } catch { /* not json */ }
++ return { status: res.status, body: parsed, text };
++}
++
++async function waitForCode(id, o = {}) {
++ let last = null;
++ for (let i = 0; i < 40; i++) {
++ last = await call('GET', `/api/v1/burners/${id}/code`, o);
++ if (last.status === 200) return last;
++ await sleep(250);
++ }
++ return last;
++}
++
++async function waitForMessages(id, o = {}) {
++ let last = null;
++ for (let i = 0; i < 40; i++) {
++ last = await call('GET', `/api/v1/burners/${id}/messages`, o);
++ if (last.status === 200 && last.body.messages.length) return last;
++ await sleep(250);
++ }
++ return last;
++}
++
++async function waitForAgentMessages(id, o = {}) {
++ let last = null;
++ for (let i = 0; i < 40; i++) {
++ last = await call('GET', `/api/v1/agent-inboxes/${id}/messages`, o);
++ if (last.status === 200 && last.body.messages.length) return last;
++ await sleep(250);
++ }
++ return last;
++}
++
++test.before(async () => {
++ await ensureDatabase(env);
++ await resetSchema(env);
++ ensureBucket();
++ stalwart = await start({ env });
++
++ network = await startNetwork();
++ const databaseUrl = urlFor(env.STALWART_TEST_DB || 'ov_mail_test', env);
++ const config = configLib.load({
++ ...env,
++ NODE_ENV: 'test',
++ DATABASE_URL: databaseUrl,
++ DATABASE_DIRECT_URL: databaseUrl,
++ OV_NETWORK_URL: network.url,
++ OV_NETWORK_INTERNAL_URL: network.url,
++ OV_NETWORK_ISSUER: network.url,
++ MAIL_BASE_DOMAIN: 'openvibe.test',
++ MAIL_MASK_DOMAIN: 'mask.openvibe.test',
++ BURNER_DOMAINS: 'burn1.openvibe.test,burn2.openvibe.test',
++ STALWART_URL: stalwart.url,
++ STALWART_ADMIN_PASSWORD: env.STALWART_ADMIN_PASSWORD,
++ MAIL_API_RATE_LIMIT_PER_MIN: '100000',
++ MAIL_MAX_MASKS: '20',
++ MAIL_MAX_LIVE_BURNERS: '5',
++ MAIL_MAX_LIVE_ANON_BURNERS: '1000',
++ MAIL_BURNERS_PER_IP_HOUR: '1000',
++ });
++ const db = await openDb(config);
++ store = createStore(db);
++
++ const captured = captureLog();
++ logs = captured.lines;
++ const realStalwart = createStalwartClient({ config, log: captured.log });
++ built = await createApp({ config, store, stalwart: realStalwart, log: captured.log });
++ await built.ctx.keys.ensure();
++
++ server = await new Promise((resolve) => {
++ const s = http.createServer(built.app);
++ s.listen(0, '127.0.0.1', () => resolve(s));
++ });
++ base = `http://127.0.0.1:${server.address().port}`;
++});
++
++test.after(async () => {
++ if (server) await new Promise((r) => server.close(r));
++ if (network) await network.close();
++ if (store) await store.close();
++ if (stalwart) await stalwart.stop();
++});
++
++test('SMTP to a burner lands, the code endpoint returns it, and the log has no body', async () => {
++ const alice = network.addUser('alice');
++ const made = await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ assert.strictEqual(made.status, 201);
++ const burner = made.body.burner;
++
++ const code = '918273';
++ const secret = 'hunter2-do-not-log-this';
++ await send({
++ port: stalwart.smtpPort,
++ to: burner.address,
++ subject: 'Your verification code',
++ text: `Your verification code is ${code}. The secret phrase is ${secret}.`,
++ });
++
++ const found = await waitForCode(burner.id, { as: alice });
++ assert.strictEqual(found.status, 200);
++ assert.strictEqual(found.body.code, code);
++
++ const messages = await waitForMessages(burner.id, { as: alice });
++ assert.strictEqual(messages.status, 200);
++ assert.ok(messages.body.messages.length >= 1);
++ assert.strictEqual(messages.body.messages[0].preview, undefined, 'a message summary has no preview');
++ assert.strictEqual(messages.body.messages[0].body, undefined, 'a message summary has no body');
++
++ const text = logs.join('\n');
++ assert.ok(!text.includes(secret), 'no message body is in a log line');
++ assert.ok(!text.includes(code), 'not even the code is in a log line');
++
++ const events = await store.db.many("SELECT envelope FROM event_outbox WHERE envelope->>'event_type' = 'mail.received'");
++ assert.ok(events.length >= 1);
++ assert.ok(!('subject' in events[0].envelope.payload), 'mail.received carries no subject');
++ assert.ok(!('body' in events[0].envelope.payload), 'mail.received carries no body');
++
++ assert.ok(listKeys().length > 0, 'Stalwart wrote the message into the S3 blob store');
++});
++
++test('a burner made for one message stops accepting after the first', async () => {
++ const alice = network.addUser('alice');
++ const made = await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h', first_message_only: true } });
++ const burner = made.body.burner;
++
++ await send({ port: stalwart.smtpPort, to: burner.address, subject: 'one', text: 'code 112233' });
++ const messages = await waitForMessages(burner.id, { as: alice });
++ assert.strictEqual(messages.status, 200);
++
++ await sleep(500);
++ await assert.rejects(
++ send({ port: stalwart.smtpPort, to: burner.address, subject: 'two', text: 'code 445566' }),
++ (err) => err.refused === true && err.smtp && err.smtp.code === 550,
++ 'the second message is refused',
++ );
++});
++
++test('forged To, Cc and Delivered-To never move a message to another burner', async () => {
++ const alice = network.addUser('alice');
++ const a = (await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
++ const b = (await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
++
++ await send({
++ port: stalwart.smtpPort,
++ to: a.address,
++ toHeader: b.address,
++ cc: b.address,
++ extraHeaders: [`Delivered-To: ${b.address}`],
++ subject: 'forged headers',
++ text: 'Your verification code is 303030',
++ });
++
++ const aCode = await waitForCode(a.id, { as: alice });
++ assert.strictEqual(aCode.status, 200, 'the real recipient gets its code');
++ assert.strictEqual(aCode.body.code, '303030');
++ const bMessages = await call('GET', `/api/v1/burners/${b.id}/messages`, { as: alice });
++ assert.strictEqual(bMessages.status, 200);
++ assert.deepStrictEqual(bMessages.body.messages, [], 'the forged recipient has no message');
++ const bCode = await call('GET', `/api/v1/burners/${b.id}/code`, { as: alice });
++ assert.strictEqual(bCode.status, 404, 'the forged recipient has no code');
++});
++
++test('three hundred messages to one burner do not hide another burner\'s message', async () => {
++ const alice = network.addUser('alice');
++ const a = (await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
++ const b = (await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
++
++ // Stalwart's default MtaStageData.maxMessages is 10 per SMTP session; use 30 sessions of 10.
++ for (let batch = 0; batch < 30; batch++) {
++ await sendMany({
++ port: stalwart.smtpPort,
++ count: 10,
++ to: a.address,
++ subject: (i) => `bulk ${batch}-${i}`,
++ text: 'bulk message',
++ });
++ }
++ await send({
++ port: stalwart.smtpPort,
++ to: b.address,
++ subject: 'the one that matters',
++ text: 'Your verification code is 404040',
++ });
++
++ const code = await waitForCode(b.id, { as: alice });
++ assert.strictEqual(code.status, 200);
++ assert.strictEqual(code.body.code, '404040');
++});
++
++test('after expiry the burner bounces and its mail is deleted', async () => {
++ const alice = network.addUser('alice');
++ const made = await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } });
++ const burner = made.body.burner;
++ await send({ port: stalwart.smtpPort, to: burner.address, subject: 'code', text: 'Your code is 778899' });
++ await waitForCode(burner.id, { as: alice });
++
++ await store.db.exec("UPDATE mail_burners SET expires_at = now() - interval '1 second' WHERE id = $1", [burner.id]);
++ const report = await runDue({ db: store.db, stalwart: built.ctx.stalwart, events: built.ctx.events, now: () => Date.now() });
++ assert.strictEqual(report.expired, 1);
++
++ await assert.rejects(
++ send({ port: stalwart.smtpPort, to: burner.address, subject: 'late', text: 'too late' }),
++ (err) => err.refused === true && err.smtp && err.smtp.code === 550,
++ 'the expired address bounces',
++ );
++ assert.strictEqual(await store.db.value('SELECT count(*)::int FROM mail_burners WHERE id = $1', [burner.id]), 0);
++});
++
++test('mail to a disabled mask bounces', async () => {
++ const alice = network.addUser('alice');
++ const made = await call('POST', '/api/v1/masks', { as: alice, json: { label: 'shop' } });
++ const mask = made.body.mask;
++ await send({ port: stalwart.smtpPort, to: mask.address, subject: 'hello', text: 'a letter' });
++
++ const off = await call('DELETE', `/api/v1/masks/${mask.id}`, { as: alice });
++ assert.strictEqual(off.status, 200);
++ assert.strictEqual(off.body.mask.enabled, false);
++
++ await assert.rejects(
++ send({ port: stalwart.smtpPort, to: mask.address, subject: 'no', text: 'a late letter' }),
++ (err) => err.refused === true && err.smtp && err.smtp.code === 550,
++ 'the disabled mask bounces',
++ );
++});
++
++test('another person gets 404 for a mask, a burner and an agent inbox', async () => {
++ const alice = network.addUser('alice');
++ const bob = network.addUser('bob');
++
++ const mask = (await call('POST', '/api/v1/masks', { as: alice, json: { label: 'private' } })).body.mask;
++ const burner = (await call('POST', '/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
++ const inbox = (await call('POST', '/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'weather' } })).body.agent_inbox;
++
++ assert.strictEqual((await call('GET', `/api/v1/masks/${mask.id}`, { as: bob })).status, 404);
++ assert.strictEqual((await call('GET', `/api/v1/burners/${burner.id}/messages`, { as: bob })).status, 404);
++ assert.strictEqual((await call('GET', `/api/v1/burners/${burner.id}/code`, { as: bob })).status, 404);
++ assert.strictEqual((await call('GET', `/api/v1/agent-inboxes/${inbox.id}/messages`, { as: bob })).status, 404);
++});
++
++test('an agent inbox receives and reads its own mail', async () => {
++ const alice = network.addUser('alice');
++ const made = await call('POST', '/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'forecast' } });
++ assert.strictEqual(made.status, 201);
++ const inbox = made.body.agent_inbox;
++ assert.match(inbox.address, /^forecast\.alice@openvibe\.test$/);
++
++ await send({ port: stalwart.smtpPort, to: inbox.address, subject: 'A question', text: 'What is the weather?' });
++ const messages = await waitForAgentMessages(inbox.id, { as: alice });
++ assert.strictEqual(messages.status, 200);
++ assert.strictEqual(messages.body.messages.length, 1);
++ assert.strictEqual(messages.body.messages[0].subject, 'A question');
++ assert.strictEqual(messages.body.messages[0].body, undefined);
++});
++
++test('a forged Delivered-To does not move mail between two agent inboxes on one mailbox', async () => {
++ const alice = network.addUser('alice');
++ const alpha = (await call('POST', '/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'alpha' } })).body.agent_inbox;
++ const beta = (await call('POST', '/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'beta' } })).body.agent_inbox;
++
++ await send({
++ port: stalwart.smtpPort,
++ to: alpha.address,
++ toHeader: beta.address,
++ cc: beta.address,
++ extraHeaders: [`Delivered-To: ${beta.address}`],
++ subject: 'for alpha',
++ text: 'What is the weather?',
++ });
++
++ const alphaMessages = await waitForAgentMessages(alpha.id, { as: alice });
++ assert.strictEqual(alphaMessages.status, 200);
++ assert.strictEqual(alphaMessages.body.messages.length, 1);
++ const betaMessages = await call('GET', `/api/v1/agent-inboxes/${beta.id}/messages`, { as: alice });
++ assert.strictEqual(betaMessages.status, 200);
++ assert.deepStrictEqual(betaMessages.body.messages, [], 'the forged headers do not reach beta');
++});
++
++test('a ten-minute burner works without an account, read with its token', async () => {
++ const made = await call('POST', '/api/v1/burners', { json: { expires: '10m' } });
++ assert.strictEqual(made.status, 201);
++ assert.strictEqual(made.body.burner.owner, null);
++ assert.ok(made.body.token && made.body.token.startsWith('mbt_'));
++
++ await send({ port: stalwart.smtpPort, to: made.body.burner.address, subject: 'Your code', text: 'Your verification code is 667788' });
++ const code = await waitForCode(made.body.burner.id, { token: made.body.token });
++ assert.strictEqual(code.status, 200);
++ assert.strictEqual(code.body.code, '667788');
++
++ const without = await call('GET', `/api/v1/burners/${made.body.burner.id}/code`);
++ assert.strictEqual(without.status, 404, 'the burner is not readable without its token');
++});
ddiff --git a/test/stalwart/run.js b/test/stalwart/run.js
nnew file mode 100644
iindex 0000000..f80cbf6
---- /dev/null
++++ b/test/stalwart/run.js
@@@ -0,0 +1,36 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * The Stalwart acceptance run. It is separate from `npm test` because it needs the pinned binary, PostgreSQL and
++ * MinIO; CI starts all three, and a workstation can use `npm run test:stalwart` after fetching the binary and
++ * pointing the STALWART_* variables at them.
++ */
++const fs = require('fs');
++const path = require('path');
++const { spawnSync } = require('child_process');
++
++const ROOT = path.join(__dirname, '..', '..');
++
++function missing() {
++ const out = [];
++ const bin = process.env.STALWART_BIN || path.join(ROOT, '.cache', 'stalwart', 'stalwart');
++ if (!fs.existsSync(bin)) out.push(`STALWART_BIN (run: node scripts/fetch-stalwart.js)`);
++ if (!process.env.STALWART_DB_PASSWORD) out.push('STALWART_DB_PASSWORD');
++ for (const name of ['STALWART_BLOB_ENDPOINT', 'STALWART_BLOB_BUCKET', 'STALWART_BLOB_ACCESS_KEY_ID', 'STALWART_BLOB_SECRET_KEY']) {
++ if (!process.env[name]) out.push(name);
++ }
++ return out;
++}
++
++const gaps = missing();
++if (gaps.length) {
++ process.stderr.write(`test:stalwart needs: ${gaps.join(', ')}\n`);
++ process.exit(2);
++}
++
++const r = spawnSync(process.execPath, ['--test', '--test-concurrency=1', path.join(__dirname, 'integration.test.js')], {
++ stdio: 'inherit',
++ env: process.env,
++});
++process.exit(r.status === null ? 1 : r.status);