openvibers/openvibe.mail Commits
OpenVibe.Mail M1: receive-only Stalwart, masks, burners and agent inboxes (#1)
Changes
nnew file mode 100644@@@ -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=60000nnew file mode 100644@@@ -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@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,9 @@++node_modules/++data/++.cache/++.env++.env.local++*.local.js++npm-debug.log*++.DS_Store++deploy/stalwart/rendered/nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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.gznnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 = 143nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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.targetnnew file mode 100644@@@ -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.targetnnew file mode 100644@@@ -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=512nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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"++ }++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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(/ /gi, ' ')++ .replace(/&/gi, '&')++ .replace(/</gi, '<')++ .replace(/>/gi, '>')++ .replace(/"/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 };nnew file mode 100644@@@ -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');++});nnew file mode 100644@@@ -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 <b>778899</b></p>' });++ assert.ok(found);++ assert.strictEqual(found.code, '778899');++});nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');++});nnew file mode 100644@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+node_modules/
+data/
+.cache/
+.env
+.env.local
+*.local.js
+npm-debug.log*
+.DS_Store
+deploy/stalwart/rendered/
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,289 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1575 @@
@@ -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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,266 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,314 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -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(/ /gi, ' ')
+ .replace(/&/gi, '&')
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/"/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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,340 @@
@@ -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');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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 <b>778899</b></p>' });
+ assert.ok(found);
+ assert.strictEqual(found.code, '778899');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,349 @@
@@ -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');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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);