openvibers/openvibe.mail Commits
Mail: public front door, fs blobs by default, and the page's API (#2)
Changes
@@@ -23,6 +23,14 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000# OV_SESSION_AUDIENCE=openvibe.network# MAIL_AUDIENCE=openvibe.mail++# Sign-in on openvibe.email (openvibe-sdk/sso, OAuth2 + PKCE). The client must be registered in the Network with++# redirect https://openvibe.email/auth/callback. COOKIE_SECURE defaults to true in production.++OV_OAUTH_CLIENT_ID=mail++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.email/auth/callback++# OV_OAUTH_SCOPE=profile++# COOKIE_SECURE=true++# 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@@@ -41,6 +49,12 @@ STALWART_DB_PORT=5432STALWART_DB_NAME=ov_mailSTALWART_DB_USER=STALWART_DB_PASSWORD=++++# Blobs: local disk by default (M1). STALWART_BLOB_STORE=s3 turns on the B2/MinIO path below; with fs only++# STALWART_BLOB_PATH and STALWART_BLOB_DEPTH matter. The depth must not change after blobs are written.++STALWART_BLOB_STORE=fs++STALWART_BLOB_PATH=/var/lib/openvibe-stalwart/blobs++STALWART_BLOB_DEPTH=2STALWART_BLOB_ENDPOINT=STALWART_BLOB_BUCKET=STALWART_BLOB_ACCESS_KEY_ID=@@@ -11,7 +11,52 @@ permissions:jobs:test:-- name: unit and Stalwart acceptance++ name: unit and Stalwart acceptance (fs blobs)++ 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++ 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 + fs blobs + 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_STORE: fs++ run: npm run test:stalwart++++ # The second half of the blob-store promise: the same acceptance run against an S3-compatible store (MinIO),++ # which is what B2 is in production. It runs beside the fs job, so it costs one runner and no serial time.++ test-s3:++ name: Stalwart acceptance (s3 blobs)runs-on: ubuntu-24.04services:postgres:@@@ -46,12 +91,6 @@ jobs:node-version: 22.22.1cache: 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)@@@ -62,6 +101,7 @@ jobs:STALWART_DB_USER: ovSTALWART_DB_PASSWORD: ov-ci-passwordSTALWART_TEST_DB: ov_mail_test++ STALWART_BLOB_STORE: s3STALWART_BLOB_ENDPOINT: http://127.0.0.1:9000STALWART_BLOB_BUCKET: ov-mailSTALWART_BLOB_REGION: us-east-1@@@ -14,6 +14,7 @@ the OpenVibe Seven Domains plan (2026-11-07) §8.| **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. |++| **Blobs** | Message blobs live on the mail server's local disk by default (`/var/lib/openvibe-stalwart/blobs`, `STALWART_BLOB_STORE=fs`). S3-compatible storage (B2 in production, MinIO in CI) stays available with `STALWART_BLOB_STORE=s3`. |The service stores only the addresses and metadata: message bodies are read from Stalwart over loopback JMAP when acode is asked for, and are never written to a log, an event or the database. A burner's account is read with no@@@ -22,16 +23,21 @@ recipient filtering at all; a mask/agent inbox in the shared person mailbox is m## 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.++All routes are under `/api/v1` and take an OpenVibe.Network token (`Authorization: Bearer …`), or the person's++same-site session cookie on openvibe.email (set by `openvibe-sdk/sso` after sign-in; `/auth/*` is the sign-in++handshake). A cross-origin browser may send a Bearer token from any first-party origin in the Contracts manifests++(CORS); a cookie-authenticated **write** must come from openvibe.email itself (`Sec-Fetch-Site`/`Origin`). 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` | `/me/mail` | a person; masks, burners and agent inboxes with state, expiry and counts (see below) |++| `POST` | `/masks` | a person; `{ purpose, expires_days?, label? }` || `GET` | `/masks` | a person || `GET` | `/masks/:id` | the owner; anyone else gets 404 |--| `DELETE` | `/masks/:id` | the owner; turns the mask off |++| `POST` | `/masks/:id/disable` | the owner; turns the mask off |++| `DELETE` | `/masks/:id` | the same, for a DELETE-shaped client || `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` |@@@ -46,6 +52,12 @@ API returns an array of `{ id, from, subject, received_at }` and nothing else: nmemory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the livenessanswer.++`GET /api/v1/me/mail` is the one call the page makes: it returns `masks`, `burners` and `agent_inboxes`, where each++item carries the Contracts resource (`mask`, `burner`, `agent_inbox`) exactly, beside the listing fields `address`,++`purpose`, `state`, a mask's `expires_at`, and `counts.messages` (the messages the service has already seen for that++address), plus totals under `counts`. No subject, sender, body or code is in it. A mask made with `expires_days` turns itself off when the date++arrives; the purge job disables the due rows and the directory stops resolving them.++## EventsThe outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:@@@ -56,9 +68,10 @@ The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:## 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.++`mail.mask@1`, `mail.burner@1`, `mail.agent-inbox@1` and `mail.message-summary@1` are in++[OpenVibe.Contracts](https://github.com/OpenVibers/OpenVibe.Contracts) v0.329.0, which this service pins. The++`/me/mail` listing is a projection above them (state, expiry, counts) — the Contracts resources do not grow extra++fields, and no Contracts release is made from this repository.## Stalwart@@@ -72,8 +85,8 @@ 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++filesystem blob store (or S3 when `STALWART_BLOB_STORE=s3`), 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 adirectory-created account the `User` role — without it Stalwart creates the account and then refuses its own mail.## Tests@@@ -81,7 +94,8 @@ directory-created account the `User` role — without it Stalwart creates the ac```shnpm test # unit: PGlite, an in-process Network and an in-memory mail servernpm run test:pg # the same through the PostgreSQL container--npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL + MinIO++npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL, fs blobs by default (MinIO with++ # STALWART_BLOB_STORE=s3)```The acceptance run (also `test/stalwart/integration.test.js` in CI) starts the pinned binary in recovery mode so@@@ -97,7 +111,9 @@ checks:- 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.++- a ten-minute burner works without an account and is read with its token;++- the message blob is on the local disk with `STALWART_BLOB_STORE=fs` (and the same run passes with `s3` in CI);++- the CORS allow-list, the same-site cookie write rule and the `/me/mail` listing.## Layout@@@ -106,6 +122,7 @@ server/ the API, domains, Stalwart client, events and the due-based pmigrations/ NNNN_*.sql (expand-only), including the directory view Stalwart readsdeploy/stalwart/ config.toml, VERSION, render.js, apply.jsdeploy/systemd/ the memory-capped Stalwart slice and both units++deploy/nginx/ openvibe.email.conf — the public door (API, health, ready, /auth)deploy/init-db.js apply migrations before systemd starts the servicetest/ unit tests and the real-Stalwart acceptance testdocs/ INSTALL.md and DNS.md@@@ -3,17 +3,12 @@"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",++ "plan": "OpenVibe Seven Domains Plan (2026-11-07) \u00a78, 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), local filesystem blobs (S3 optional)","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"-- ],++ "domain": "openvibe.email; receive-only MX. The API is public through nginx (deploy/nginx/openvibe.email.conf); no UI yet.",++ "contracts": "openvibe-contracts v0.329.0",++ "contractsProposed": [],"sdk": "openvibe-sdk v0.97.1","shared": "openvibe-shared v4.135.0","stalwart": {@@@ -21,7 +16,7 @@"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)",++ "blobStore": "local filesystem /var/lib/openvibe-stalwart/blobs by default (STALWART_BLOB_STORE=fs); S3-compatible optional (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"@@@ -51,7 +46,8 @@"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)"++ "acceptance": "node test/stalwart/run.js (pinned Stalwart + PostgreSQL, fs blobs by default and one S3 CI run; 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, blob on disk)",++ "frontdoor": "node test/frontdoor.test.js (CORS allow-list, same-site cookie write rule, /me/mail)"},"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.",@@@ -59,6 +55,21 @@"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"-- ]++ "the mail server has no password-authenticated listener and no relay: domains are allowRelaying=false and the submission/POP3/Sieve/HTTPS listeners are removed",++ "a cookie-authenticated write must come from openvibe.email (Sec-Fetch-Site/Origin)",++ "CORS is an exact allow-list from the Contracts manifests; no wildcard and no credentialed cross-origin request"++ ],++ "publicApi": {++ "base": "https://openvibe.email",++ "paths": [++ "/api/v1/",++ "/api/health",++ "/api/ready",++ "/auth/"++ ],++ "placeholder": "/ answers a placeholder page until the UI lands",++ "nginx": "deploy/nginx/openvibe.email.conf",++ "cors": "Bearer from any first-party origin in the Contracts manifests; cookie writes only same-origin on openvibe.email",++ "trustProxy": 2++ }}nnew file mode 100644@@@ -0,0 +1,125 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.email — Nginx configuration (REFERENCE)++##++## Installed at /etc/nginx/sites-available/openvibe.email.conf and linked in sites-enabled; the parked-domains.conf++## block for openvibe.email and www.openvibe.email must be removed first, or this vhost never wins the name.++##++## The Mail Node service listens on 127.0.0.1:5250 and serves only JSON. This vhost terminates TLS and is the++## public door:++##++## / a placeholder until the openvibe.email page lands (not proxied)++## /api/v1/ the API; the app enforces Network auth, CORS and the write rules++## /api/health, /api/ready the machine answers++## /auth/ sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback,++## /auth/me, /auth/logout. The session cookie is host-only on openvibe.email.++##++## Everything else answers 404. The API body cap is small: a mask label or a purpose, never a message. No nginx++## rate limit is added: the app's 240/minute per address (MAIL_API_RATE_LIMIT_PER_MIN) and the burner counters are++## the limits, and they must see the real client address.++##++## Cloudflare: the host's /etc/nginx/conf.d/cloudflare-realip.conf is included from nginx.conf's http block for++## every vhost; it restores the person's address from CF-Connecting-IP. That is why this file, like the other++## house vhosts, does not repeat the set_real_ip_from list, and why X-Forwarded-For is set to $remote_addr rather++## than $proxy_add_x_forwarded_for. The app runs TRUST_PROXY=2 (Cloudflare + nginx).++## ═══════════════════════════════════════════════════════════════++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.email www.openvibe.email;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.email$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.email;++++ ssl_certificate /etc/letsencrypt/live/openvibe.email/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.email/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.email$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.email;++ include snippets/security-txt.conf; # /.well-known/security.txt++++ ssl_certificate /etc/letsencrypt/live/openvibe.email/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.email/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.email.access.log;++ error_log /var/log/nginx/openvibe.email.error.log;++++ # The API's JSON body is a label or a purpose; the mail itself never comes through here.++ client_max_body_size 64k;++++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options DENY always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types application/json application/problem+json text/html text/plain;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── the placeholder until Opus's page lands ─────────────++ # Deliberately not proxied: only the API and the sign-in routes reach the service.++ location = / {++ default_type text/html;++ return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>OpenVibe.Mail</title><style>:root{color-scheme:dark light}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0a0f1c;color:#e6edf7;font:16px/1.6 system-ui,sans-serif}main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}p{color:#96a7c2}</style></head><body><main><h1>OpenVibe.Mail</h1><p>The mailbox page is on its way. The API is live at <code>/api/v1/</code>.</p></main></body></html>';++ }++++ # ── the machine answers ─────────────────────────────────++ location = /api/health {++ proxy_pass http://127.0.0.1:5250;++ }++++ location = /api/ready {++ proxy_pass http://127.0.0.1:5250;++ }++++ # ── the API ─────────────────────────────────────────────++ # The app answers the CORS preflight and sets the headers; nginx must not add any of its own.++ location /api/v1/ {++ proxy_pass http://127.0.0.1:5250;++ }++++ # ── Network SSO (openvibe-sdk/sso) ──────────────────────++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.++ location = /auth/me {++ proxy_pass http://127.0.0.1:5250;++ }++++ location /auth/ {++ proxy_pass http://127.0.0.1:5250;++ }++++ # ── everything else ─────────────────────────────────────++ # No generic /api/ proxy: only the paths above are the public API.++ location /api/ { return 404; }++ location ~* ^/(metrics|internal)(/|$) { return 404; }++ location / { return 404; }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}@@@ -9,7 +9,8 @@* 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++ * blob store local disk by default (M1), or S3-compatible (B2 in production); the S3 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*@@@ -71,6 +72,41 @@ function setProblems(label, result) {return problems;}++/**++ * The BlobStore JMAP object for the plan. fs writes into a directory on this host; s3 reads its two keys from the++ * environment (never from the plan or a rendered file) and points them at the S3-compatible endpoint.++ */++function blobStoreFor(blob, env = process.env) {++ const kind = String((blob && blob.kind) || 'fs').toLowerCase();++ if (kind === 'fs') {++ if (!blob.path) throw new Error('the fs blob store needs a path');++ return {++ '@type': 'FileSystem',++ path: blob.path,++ depth: Number.isInteger(blob.depth) ? blob.depth : 2,++ };++ }++ if (kind !== 's3') throw new Error(`unknown blob store kind: ${kind}`);++ 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}`);++ }++ return {++ '@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,++ };++}++async function apply(plan, client, env = process.env) {const problems = [];@@@ -169,25 +205,7 @@ async function apply(plan, client, env = process.env) {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 blobStore = blobStoreFor(plan.blob_store, env);const blobResult = await client.one('x:BlobStore/set', { update: { singleton: blobStore } }, 'b');problems.push(...setProblems('blob store', blobResult));@@@ -286,4 +304,4 @@ if (require.main === module) {});}--module.exports = { apply, createClient };++module.exports = { apply, createClient, blobStoreFor };@@@ -33,8 +33,17 @@ ssl = falsepool_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"++# M1's default is the mail server's own disk: the message blobs live beside the database, under the service's++# StateDirectory (the systemd unit already allows writes there). STALWART_BLOB_PATH overrides the directory and++# STALWART_BLOB_DEPTH the number of hash levels; the depth must not change after blobs have been written.++kind = "fs"++path = "/var/lib/openvibe-stalwart/blobs"++depth = 2++++# S3-compatible storage (Backblaze B2 in production, MinIO in CI) stays available:++# STALWART_BLOB_STORE=s3++# The endpoint is a custom S3 region to Stalwart, and the keys are the *names* of environment variables that++# apply.js resolves in memory. These values are used only when STALWART_BLOB_STORE=s3.endpoint = "https://s3.eu-central-003.backblazeb2.com"bucket = "openvibe-mail"region = "eu-central-003"@@@ -8,7 +8,9 @@* 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.++ * the *names* of environment variables that apply.js resolves in memory. M1's default blob store is the local++ * filesystem (STALWART_BLOB_STORE=fs); STALWART_BLOB_STORE=s3 keeps the B2/MinIO path available for a host that++ * wants blobs off the mail server's disk.** node deploy/stalwart/render.js [--config PATH] [--out DIR]*/@@@ -23,46 +25,43 @@ function arg(name, fallback) {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) {++function render(config, env = process.env) {++ const getEnv = (name, fallback) => {++ const v = env[name];++ return v === undefined || v === '' ? fallback : v;++ };++ const getString = (name, fallback) => String(getEnv(name, fallback)).trim();++ const getInt = (name, fallback) => {++ const v = getEnv(name, fallback);++ 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;++ };++ const getBool = (name, fallback) => {++ const v = getEnv(name, fallback);++ if (v === undefined || v === '') return fallback;++ return /^(1|true|yes|on)$/i.test(String(v).trim());++ };++ const getList = (name, fallback) => {++ const v = getEnv(name, fallback);++ if (v === undefined || v === '') return fallback;++ return String(v).split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);++ };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');++ const host = getString('STALWART_DB_HOST', db.host);++ const port = getInt('STALWART_DB_PORT', db.port);++ const database = getString('STALWART_DB_NAME', db.database);++ const user = getString('STALWART_DB_USER', db.user);++ const passwordEnv = getString('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');@@@ -74,41 +73,58 @@ function render(config) {database,authUsername: user,authSecret: { '@type': 'EnvironmentVariable', variableName: passwordEnv },-- useTls: envBool('STALWART_DB_SSL', Boolean(db.ssl)),++ useTls: getBool('STALWART_DB_SSL', Boolean(db.ssl)),allowInvalidCerts: false,-- poolMaxConnections: envInt('STALWART_DB_POOL_MAX', db.pool_max_connections || 20),++ poolMaxConnections: getInt('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 || []);++ const base = getString('MAIL_BASE_DOMAIN', domains.base);++ const mask = getString('MAIL_MASK_DOMAIN', domains.mask);++ const burners = getList('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 smtpBind = getString('STALWART_SMTP_BIND', config.listeners.smtp_bind);++ const smtpPort = getInt('STALWART_SMTP_PORT', config.listeners.smtp_port);++ const httpBind = getString('STALWART_HTTP_BIND', config.listeners.http_bind);++ const httpPort = getInt('STALWART_HTTP_PORT', config.listeners.http_port);++ const imapBind = getString('STALWART_IMAP_BIND', config.listeners.imap_bind);++ const imapPort = getInt('STALWART_IMAP_PORT', config.listeners.imap_port);++++ // M1 defaults to blobs on the mail server's own disk. fs is the new default; s3 stays available (B2 in++ // production, MinIO in CI) with the existing variables.++ const blobKind = String(getString('STALWART_BLOB_STORE', blob.kind || 'fs')).toLowerCase();++ if (blobKind !== 'fs' && blobKind !== 's3') {++ throw new Error('config.toml: blob_store.kind / STALWART_BLOB_STORE must be "fs" or "s3"');++ }++ let blobStore;++ if (blobKind === 'fs') {++ blobStore = {++ kind: 'fs',++ path: getString('STALWART_BLOB_PATH', blob.path || '/var/lib/openvibe-stalwart/blobs'),++ depth: getInt('STALWART_BLOB_DEPTH', blob.depth || 2),++ };++ required(blobStore.path, 'STALWART_BLOB_PATH');++ if (blobStore.depth < 0 || blobStore.depth > 5) throw new Error('STALWART_BLOB_DEPTH must be from 0 to 5');++ } else {++ const s3 = {++ kind: 's3',++ endpoint: getString('STALWART_BLOB_ENDPOINT', blob.endpoint),++ bucket: getString('STALWART_BLOB_BUCKET', blob.bucket),++ region: getString('STALWART_BLOB_REGION', blob.region),++ accessKeyIdEnv: getString('STALWART_BLOB_ACCESS_KEY_ID_ENV', blob.access_key_id_env || 'STALWART_BLOB_ACCESS_KEY_ID'),++ secretKeyEnv: getString('STALWART_BLOB_SECRET_KEY_ENV', blob.secret_key_env || 'STALWART_BLOB_SECRET_KEY'),++ allowInvalidCerts: getBool('STALWART_BLOB_ALLOW_INVALID_CERTS', Boolean(blob.allow_invalid_certs)),++ verifyAfterWrite: getBool('STALWART_BLOB_VERIFY_AFTER_WRITE', blob.verify_after_write !== false),++ timeoutMs: getInt('STALWART_BLOB_TIMEOUT_MS', blob.timeout_ms || 30000),++ maxRetries: getInt('STALWART_BLOB_MAX_RETRIES', blob.max_retries || 3),++ keyPrefix: getString('STALWART_BLOB_KEY_PREFIX', blob.key_prefix || '') || null,++ };++ required(s3.endpoint, 'STALWART_BLOB_ENDPOINT');++ required(s3.bucket, 'STALWART_BLOB_BUCKET');++ blobStore = s3;++ }const directory = config.directory || {};const spam = config.spam || {};@@@ -119,7 +135,7 @@ function render(config) {generated_by: 'deploy/stalwart/render.js',version: 1,server: {-- default_hostname: envString('STALWART_DEFAULT_HOSTNAME', config.server.default_hostname),++ default_hostname: getString('STALWART_DEFAULT_HOSTNAME', config.server.default_hostname),},domains: {base,@@@ -136,8 +152,8 @@ function render(config) {},blob_store: blobStore,spam: {-- enabled: envBool('STALWART_SPAM_ENABLED', spam.enabled !== false),-- score_spam: Number(envString('STALWART_SPAM_SCORE', spam.score_spam || 5.0)),++ enabled: getBool('STALWART_SPAM_ENABLED', spam.enabled !== false),++ score_spam: Number(getString('STALWART_SPAM_SCORE', spam.score_spam || 5.0)),},listeners: [{ name: 'smtp', protocol: 'smtp', bind: [`${smtpBind}:${smtpPort}`], use_tls: true, implicit_tls: false },@@@ -10,8 +10,11 @@ not collide with the plan, then the plan's listeners, then the Node service.- 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).++- A writable local directory for message blobs, `/var/lib/openvibe-stalwart/blobs` by default (M1). A Backblaze++ B2 bucket is optional: set `STALWART_BLOB_STORE=s3` to keep blobs off the mail server's disk.- Node 22 on the host.++- An OAuth client in OpenVibe.Network for `mail`: redirect `https://openvibe.email/auth/callback` (the Network++ seeds first-party clients in its database; this one is added with the host install, not by this repository).- The pinned Stalwart binary, see [`deploy/stalwart/VERSION`](../deploy/stalwart/VERSION).Create the service user once:@@@ -54,9 +57,16 @@ the service's migrations create `mail_*` and the directory view. They share the## 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.++**M1 default: local disk.** Stalwart writes message blobs under `/var/lib/openvibe-stalwart/blobs` (the unit's++`StateDirectory=openvibe-stalwart` already owns and writably mounts that tree). Nothing else is needed. The depth++(`STALWART_BLOB_DEPTH`, default 2) decides the hash directory layout and **must not change** once blobs exist: the++server would look in the wrong directories and stop finding old messages.++++**Optional: Backblaze B2 (or any S3-compatible store).** Create a bucket `openvibe-mail` and an application key++limited to it, then set `STALWART_BLOB_STORE=s3` and the `STALWART_BLOB_*` values in++`/etc/openvibe/stalwart.env`. The endpoint is a custom S3 region to Stalwart (for example++`https://s3.eu-central-003.backblazeb2.com`), the region is `eu-central-003`. The service never reads blobs++directly either way.## 3. The Stalwart binary@@@ -82,11 +92,15 @@ STALWART_DB_PORT=5432STALWART_DB_NAME=ov_mailSTALWART_DB_USER=ov_mailSTALWART_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++STALWART_BLOB_STORE=fs++STALWART_BLOB_PATH=/var/lib/openvibe-stalwart/blobs++STALWART_BLOB_DEPTH=2++# Optional, only when STALWART_BLOB_STORE=s3:++#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:@@@ -111,11 +125,14 @@ STALWART_ADMIN_PASSWORD=REPLACE_WITH_THE_RECOVERY_ADMIN_PASSWORDMAIL_EVENTS_URL=http://127.0.0.1:4300MAIL_EVENTS_CLIENT_ID=mailMAIL_EVENTS_CLIENT_SECRET=REPLACE++OV_OAUTH_CLIENT_ID=mail++OV_OAUTH_CLIENT_SECRET=REPLACE_WITH_THE_NETWORK_CLIENT_SECRET++# COOKIE_SECURE defaults to true in production.```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.++the direct connection. The Stalwart env file needs the S3 keys only when `STALWART_BLOB_STORE=s3`; the running server reads the blob++store from its database. The Network client secret is needed by the sign-in exchange and is never logged.## 5. Render the configuration@@@ -162,7 +179,9 @@ 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.++The first pass creates the SQL directory, the domains, the local Filesystem blob store, the spam setting and the++three listeners. (With `STALWART_BLOB_STORE=s3` it creates the S3 store instead and the two keys are read from++`/etc/openvibe/stalwart.env`.)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@@@ -208,7 +227,35 @@ 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 afailure here is easier to read. The service adds the domains it knows on start; there is no pool mailbox.--## 9. DNS++## 9. The public door (nginx)++++The parked `openvibe.email` block in `/etc/nginx/sites-enabled/parked-domains.conf` redirects the name to Chat.++Remove it (or comment it out) first; otherwise nginx keeps answering with the parked server. Then install the++repository's vhost:++++```sh++sudo install -m 0644 deploy/nginx/openvibe.email.conf /etc/nginx/sites-available/openvibe.email.conf++sudo ln -sfn /etc/nginx/sites-available/openvibe.email.conf /etc/nginx/sites-enabled/openvibe.email.conf++sudo nginx -t && sudo systemctl reload nginx++```++++The `server_name` lines are:++++```nginx++server_name openvibe.email www.openvibe.email; # port 80: redirect to https++server_name www.openvibe.email; # port 443: redirect to the apex++server_name openvibe.email; # port 443: the API and the sign-in routes++```++++The host's `/etc/nginx/conf.d/cloudflare-realip.conf` (included globally from `nginx.conf`) restores the person's++address from `CF-Connecting-IP` for every vhost; this file does not repeat it, like the rest of the house. Only++`/api/v1/`, `/api/health`, `/api/ready` and the Network sign-in routes (`/auth/`) are proxied to the Mail service;++`/` is the placeholder until the page lands.++++Register the Network OAuth client for `mail` with redirect `https://openvibe.email/auth/callback`, and put its++secret in `OV_OAUTH_CLIENT_SECRET` in `/etc/openvibe/mail.env`, or sign-in cannot finish.++++## 10. DNSPublish the records in [`docs/DNS.md`](DNS.md), keeping the mail A/AAAA records **DNS-only** in Cloudflare. Wait forthe MX, then test from outside:@@@ -218,7 +265,7 @@ dig +short MX openvibe.emailpython3 -c "import smtplib; s=smtplib.SMTP('mail.openvibe.email',25,timeout=10); print(s.ehlo()[1]); s.quit()"```--## 10. Verify++## 11. Verify```shcurl -fsS http://127.0.0.1:5250/api/health@@@ -228,9 +275,17 @@ journalctl -u openvibe-stalwart -u openvibe-mail --since '10 min ago' --no-pager```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.++create a burner and use `GET /api/v1/burners/:id/code`). A body must never appear in either journal. Check that++the public door answers:++++```sh++curl -fsS https://openvibe.email/api/health++curl -fsS https://openvibe.email/api/ready++curl -fsS -o /dev/null -w '%{http_code}\n' https://openvibe.email/ # 200 placeholder++curl -fsS -o /dev/null -w '%{http_code}\n' https://openvibe.email/api/v1/masks # 401 without a token++```--## 11. Firewall and things not to do++## 12. 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.@@@ -239,12 +294,12 @@ create a burner and use `GET /api/v1/burners/:id/code`). A body must never appea- The memory cap is the slice's (`/etc/systemd/system/openvibe-stalwart.slice`). The host OOMed on 2026-11-07; donot raise `MemoryMax` without measuring the spam filter and a busy mailbox first.--## 12. Rollback++## 13. Rollback```shsudo systemctl stop openvibe-mail.service openvibe-stalwart.servicesudo 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++The data stays in PostgreSQL (`ov_mail`) and the blob store (local disk, or B2 with `STALWART_BLOB_STORE=s3`). Removing the DNS MX records stops new mail; mail already queued onother servers retries until their expiry, so keep the host reachable until the queues drain.nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- OpenVibe.Mail — a mask can be given a life.++--++-- POST /api/v1/masks accepts expires_days: the mask turns itself off when the date arrives, so a per-site address++-- made for one purchase does not outlive the purchase by accident. expires_at is when that happens; NULL means the++-- mask lives until the person turns it off. The purge job disables the due rows (enabled = false), and the SQL++-- directory Stalwart reads stops resolving them in the same moment.++--++-- The Contract resource (mail.mask@1) stays as it is: it has no expiry field, so only /me/mail's listing metadata++-- carries expires_at.++++ALTER TABLE mail_masks ADD COLUMN IF NOT EXISTS expires_at timestamptz;++++CREATE INDEX IF NOT EXISTS mail_masks_due_idx ON mail_masks (expires_at) WHERE enabled AND expires_at IS NOT NULL;@@@ -13,7 +13,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-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.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"@@@ -1063,9 +1063,9 @@}},"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==",++ "version": "0.329.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",++ "integrity": "sha512-bSCmuAjR83aRqMwV6DcFzxsZOizJWuKwBKEnCO71r0fCuFyxQJalEgCJG6koFZ4vhVhFCLfAxKmgRLRqMZhZpQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,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-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.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"@@@ -5,9 +5,11 @@* database and a stand-in for the mail server.** /api/health, /api/ready the machine answers++ * /auth/* Network SSO with PKCE (openvibe-sdk/sso)* /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.++ * There are no pages: M1 has no site, only an API for the apps and the agents of the house. nginx answers `/` with++ * a placeholder until the page lands.** 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@@@ -22,10 +24,12 @@ const configLib = require('./config');const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createPrincipal } = require('./http/principal');++const { createCors } = require('./http/cors');const { createApi } = require('./http/api');const { createServiceReadiness } = require('./observability');const { createStalwartClient } = require('./stalwart/client');const { createEvents } = require('./events');++const { createSsoClient } = require('openvibe-sdk/sso');const VERSION = require('../package.json').version;@@@ -42,6 +46,22 @@ async function createApp(opts = {}) {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 sso = opts.sso || createSsoClient({++ site: 'mail',++ siteName: 'OpenVibe.Mail',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ networkUrl: config.network.url,++ networkInternalUrl: config.network.internalUrl,++ issuer: config.network.issuer,++ audience: [config.network.sessionAudience],++ secureCookies: config.cookies.secure,++ fetch: fetchImpl,++ log,++ });const app = express();app.disable('x-powered-by');@@@ -50,12 +70,36 @@ async function createApp(opts = {}) {app.use(contracts.http.middleware());app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));app.use(express.json({ limit: '16kb' }));++ // Nothing here is cacheable: a person's addresses and sign-in are private, and a machine answer is live.++ app.use(['/api', '/auth'], (_req, res, next) => {++ res.set('Cache-Control', 'no-store');++ next();++ });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);++ // Sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback, /auth/me, /auth/logout. The++ // session lives in the SDK's host-only ov_token cookie on openvibe.email. A request that already carries a++ // Bearer token is judged by that token alone, so the cookie is not even read for it.++ app.use((req, res, next) => (req.headers.authorization ? next() : sso.optionalAuth()(req, res, next)));++ app.use('/auth', rateLimit({++ windowMs: 15 * 60_000,++ limit: 60,++ standardHeaders: true,++ legacyHeaders: false,++ handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', {++ detail: 'Too many sign-in requests from this address; retry shortly.',++ ctx: req.ov,++ }),++ }));++ app.use('/auth', sso.router(express));++++ // CORS: a Bearer token may be sent from any first-party origin in the manifests; a cookie may not cross sites.++ app.use('/api/v1', createCors({ baseUrl: config.baseUrl }));++app.use('/api/v1', rateLimit({windowMs: 60_000,limit: config.limits.apiPerMinute,@@@ -89,7 +133,7 @@ async function createApp(opts = {}) {return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error.', ctx: req.ov });});-- return { app, ctx: { config, store, keys, stalwart, events, principal, log } };++ return { app, ctx: { config, store, keys, stalwart, events, principal, sso, log } };}module.exports = { createApp, VERSION };@@@ -44,6 +44,7 @@ function load(overrides = null) {const baseDomain = domain('MAIL_BASE_DOMAIN', 'openvibe.email');const maskDomain = domain('MAIL_MASK_DOMAIN', `mask.${baseDomain}`);const burnerDomains = list('BURNER_DOMAINS');++ const baseUrl = String(env.BASE_URL || `https://${baseDomain}`).replace(/\/+$/, '');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)');@@@ -64,7 +65,7 @@ function load(overrides = null) {service: 'mail',port: int('PORT', 5250),host: env.HOST || '127.0.0.1',-- baseUrl: env.BASE_URL || `https://${baseDomain}`,++ baseUrl,trustProxy: int('TRUST_PROXY', 0),db: {@@@ -86,6 +87,16 @@ function load(overrides = null) {sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',},++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'mail',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: env.OV_OAUTH_SCOPE || 'profile',++ },++ cookies: {++ secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction,++ },++addresses: {base: baseDomain,mask: maskDomain,@@@ -37,7 +37,7 @@ function shape(row) {return {id: row.id,address: row.address,-- owner: row.owner_subject || null,++ owner: row.owner_subject || row.owner || 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,@@@ -138,11 +138,30 @@ async function createBurner({/** The person's burners, newest first. */async function listBurners({ db, owner }) {++ const rows = await listBurnerRows({ db, owner });++ return rows.map(shape);++}++++/** The person's burners with the fields /me/mail needs beyond the contract (first message and expiry). */++async function listBurnerRows({ db, owner }) {const rows = await db.many(sql`-- SELECT id, owner_subject, address, expires_at, first_message_only, created_at++ SELECT id, owner_subject, address, expires_at, first_message_only, first_message_at, created_atFROM mail_burners WHERE owner_subject = ${owner}ORDER BY created_at DESC`);-- return rows.map(shape);++ return rows.map((row) => ({++ ...shape(row),++ first_message_at: row.first_message_at instanceof Date++ ? row.first_message_at.toISOString()++ : (row.first_message_at || null),++ }));++}++++/** live (accepting mail), consumed (first_message_only and it has taken one) or expired. */++function stateOf(row, now = () => Date.now()) {++ if (!row) return null;++ if (new Date(row.expires_at).getTime() <= now()) return 'expired';++ if (row.first_message_only && row.first_message_at) return 'consumed';++ return 'live';}/** One burner, whoever's it is. The caller decides what it may do with it. */@@@ -254,6 +273,8 @@ module.exports = {ANON_EXPIRY,createBurner,listBurners,++ listBurnerRows,++ stateOf,findBurner,authorizeBurner,mailboxOf,@@@ -6,6 +6,10 @@* 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.++ *++ * A mask may also be given a life: `expires_days` stores when it is due to turn itself off. The purge job disables++ * it then, so the expiry is a fact of the database, not a date the page has to remember. `expires_at` is listing++ * metadata; the mail.mask@1 resource stays exactly the Contract (it has no expiry field).*/const { sql } = require('openvibe-sdk/db');const { ids } = require('openvibe-contracts');@@@ -14,11 +18,14 @@ const { ensureOwnerMailbox } = require('./mailboxes');const { countMasks } = require('./limits');const WORDS_FOR_LABEL = 1;++const MAX_EXPIRY_DAYS = 3650;++const DAY_MS = 24 * 60 * 60 * 1000;function shape(row) {if (!row) return null;return {id: row.id,++ owner: row.owner_subject || row.owner || null,address: row.address,label: row.label || null,purpose: row.purpose || null,@@@ -27,6 +34,34 @@ function shape(row) {};}++/** A row of the person's list, with the fields /me/mail needs beyond the contract (expiry). */++function listShape(row) {++ const mask = shape(row);++ if (!mask) return null;++ return {++ ...mask,++ expires_at: row.expires_at instanceof Date ? row.expires_at.toISOString() : (row.expires_at || null),++ };++}++++function stateOf(row, now = () => Date.now()) {++ if (row.expires_at && new Date(row.expires_at).getTime() <= now()) return 'expired';++ if (!row.enabled) return 'disabled';++ return 'active';++}++++function expiryDays(value) {++ if (value === undefined || value === null || value === '') return null;++ const n = Number(value);++ if (!Number.isInteger(n) || n < 1 || n > MAX_EXPIRY_DAYS) {++ const err = new Error(`expires_days must be a whole number from 1 to ${MAX_EXPIRY_DAYS}`);++ err.code = 'mail.bad_expiry';++ err.status = 400;++ throw err;++ }++ return n;++}++/*** Make a mask for a person.*@@@ -34,9 +69,13 @@ function shape(row) {* 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() }) {++async function createMask({++ db, config, events, owner, label = null, purpose = null, expiresDays = null, now = () => Date.now(),++}) {const words = maskWords(label || purpose || '');const limit = config.limits.maxMasks;++ const days = expiryDays(expiresDays);++ const expiresAt = days ? new Date(now() + days * DAY_MS) : null;return db.tx(async (t) => {const mailbox = await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });@@@ -50,14 +89,15 @@ async function createMask({ db, config, events, owner, label = null, purpose = nfor (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)++ INSERT INTO mail_masks (id, owner_subject, address, label, purpose, enabled, expires_at)++ VALUES (${`msk_${ids.ulid(now())}`}, ${owner}, ${address}, ${label || null}, ${purpose || null}, true,++ ${expiresAt ? expiresAt.toISOString() : null})ON CONFLICT (address) DO NOTHING-- RETURNING id, address, label, purpose, enabled, created_at`);++ RETURNING id, owner_subject, address, label, purpose, enabled, expires_at, created_at`);if (!row) continue;const mask = shape(row);if (events) await events.outbox.emit(events.maskCreated({ owner, mask }));-- return mask;++ return shape(row);}const err = new Error('the address is taken; try again');err.code = 'mail.address_taken';@@@ -66,19 +106,25 @@ async function createMask({ db, config, events, owner, label = null, purpose = n});}--/** The person's masks, newest first. */++/** The person's masks, newest first, in the mail.mask@1 shape. */async function listMasks({ db, owner }) {++ const rows = await listMaskRows({ db, owner });++ return rows.map(shape);++}++++/** The person's masks with the listing metadata (/me/mail): the contract fields and the expiry. */++async function listMaskRows({ db, owner }) {const rows = await db.many(sql`-- SELECT id, address, label, purpose, enabled, created_at++ SELECT id, owner_subject, address, label, purpose, enabled, expires_at, created_atFROM mail_masks WHERE owner_subject = ${owner}ORDER BY created_at DESC`);-- return rows.map(shape);++ return rows.map(listShape);}/** 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++ SELECT id, owner_subject, address, label, purpose, enabled, expires_at, created_atFROM mail_masks WHERE id = ${id} AND owner_subject = ${owner}`);return shape(row);}@@@ -92,8 +138,8 @@ async function disableMask({ db, stalwart, owner, id, now = () => Date.now() })const row = await t.maybe(sql`UPDATE mail_masks SET enabled = falseWHERE id = ${id} AND owner_subject = ${owner} AND enabled-- RETURNING id, address, label, purpose, enabled, created_at`);-- return shape(row);++ RETURNING id, owner_subject, address, label, purpose, enabled, expires_at, created_at`);++ return row || null;});if (!mask) return null;if (stalwart) {@@@ -104,7 +150,43 @@ async function disableMask({ db, stalwart, owner, id, now = () => Date.now() })(console.warn || console.log)(`[mail] the mail server did not take the cache notice: ${err && err.message}`);}}-- return mask;++ return shape(mask);++}++++/**++ * The masks whose life has run out: disable them and tell the mail server to forget them.++ *++ * Due-based like the burner purge: only rows whose expires_at has passed are touched, so a service that was down++ * for a day catches up on its first run. A failure leaves the row enabled; the next run tries again.++ */++async function disableDueMasks({ db, stalwart, now = () => Date.now() }) {++ const rows = await db.tx(async (t) => t.many(sql`++ UPDATE mail_masks SET enabled = false++ WHERE enabled++ AND expires_at IS NOT NULL++ AND expires_at <= ${new Date(now()).toISOString()}++ RETURNING id, address`));++ if (rows.length && stalwart) {++ try {++ await stalwart.invalidateCaches();++ } catch (err) {++ (console.warn || console.log)(`[mail] the mail server did not take the mask-expiry cache notice: ${err && err.message}`);++ }++ }++ return rows.length;}--module.exports = { createMask, listMasks, findMask, disableMask, shape, WORDS_FOR_LABEL };++module.exports = {++ createMask,++ listMasks,++ listMaskRows,++ findMask,++ disableMask,++ disableDueMasks,++ shape,++ listShape,++ stateOf,++ expiryDays,++ WORDS_FOR_LABEL,++ MAX_EXPIRY_DAYS,++};@@@ -63,4 +63,22 @@ async function observedIds({ db, address }) {return rows.map((r) => r.message_id);}--module.exports = { summary, observe, listForAddress, observedIds };++/**++ * How many messages the service has already seen for each of these addresses.++ *++ * This is the count of the observation table, not a walk of every mailbox: opening an address's messages or its++ * code writes the row. It is deliberately cheap and carries no subject, sender or body — /me/mail draws a badge,++ * it does not read mail.++ */++async function countsByAddress({ db, addresses = [] }) {++ const list = [...new Set((addresses || []).filter(Boolean))];++ if (!list.length) return new Map();++ const rows = await db.many(sql`++ SELECT address, count(*)::int AS messages++ FROM mail_observed_messages++ WHERE address = ANY(${list})++ GROUP BY address`);++ return new Map(rows.map((r) => [r.address, Number(r.messages) || 0]));++}++++module.exports = { summary, observe, listForAddress, observedIds, countsByAddress };@@@ -3,10 +3,13 @@/*** The API of OpenVibe.Mail, v1.*++ * GET /api/v1/me/mail the person's masks, burners and agent inboxes, with state and counts++ ** 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/masks/:id/disable turn a mask off (it bounces from then on)++ * DELETE /api/v1/masks/:id the same, for a DELETE-shaped client** POST /api/v1/burners a burner (with an account, or the ten-minute one without)* GET /api/v1/burners the person's burners@@@ -30,6 +33,7 @@ const { http } = require('openvibe-contracts');const masks = require('../domain/masks');const burners = require('../domain/burners');const agentInboxes = require('../domain/agent-inboxes');++const messages = require('../domain/messages');function createApi({ db, config, stalwart, events, principal, log = console }) {const router = express.Router();@@@ -54,6 +58,78 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {return http.sendProblem(res, status, code, { detail, ctx: req.ov });}++ // ---- the person's front door ---------------------------------------------------------------++++ /**++ * Everything the page draws, in one read: the three kinds of address, each with its state and how many++ * messages the service has seen for it, plus the totals.++ *++ * Every `mask`, `burner` and `agent_inbox` object inside an item is exactly the Contracts resource++ * (mail.mask@1, mail.burner@1, mail.agent-inbox@1); `state`, `expires_at` (a mask) and `counts` sit beside it.++ * No message body, subject, sender or code is in this answer.++ */++ router.get('/api/v1/me/mail', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {++ const owner = ownerOf(req);++ const [maskRows, burnerRows, inboxes] = await Promise.all([++ masks.listMaskRows({ db, owner }),++ burners.listBurnerRows({ db, owner }),++ agentInboxes.listAgentInboxes({ db, owner }),++ ]);++ const addresses = [++ ...maskRows.map((m) => m.address),++ ...burnerRows.map((b) => b.address),++ ...inboxes.map((i) => i.address),++ ];++ const seen = await messages.countsByAddress({ db, addresses });++ const countOf = (address) => seen.get(address) || 0;++ const now = () => Date.now();++++ const maskItems = maskRows.map((row) => ({++ mask: masks.shape(row),++ address: row.address,++ purpose: row.purpose || null,++ state: masks.stateOf(row, now),++ expires_at: row.expires_at || null,++ counts: { messages: countOf(row.address) },++ }));++ const burnerItems = burnerRows.map((row) => ({++ burner: burners.shape(row),++ address: row.address,++ purpose: null,++ state: burners.stateOf(row, now),++ counts: { messages: countOf(row.address) },++ }));++ const inboxItems = inboxes.map((inbox) => ({++ agent_inbox: inbox,++ address: inbox.address,++ purpose: null,++ state: 'active',++ counts: { messages: countOf(inbox.address) },++ }));++++ return res.json({++ masks: maskItems,++ burners: burnerItems,++ agent_inboxes: inboxItems,++ counts: {++ masks: {++ total: maskItems.length,++ active: maskItems.filter((m) => m.state === 'active').length,++ messages: maskItems.reduce((n, m) => n + m.counts.messages, 0),++ },++ burners: {++ total: burnerItems.length,++ live: burnerItems.filter((b) => b.state === 'live').length,++ messages: burnerItems.reduce((n, b) => n + b.counts.messages, 0),++ },++ agent_inboxes: {++ total: inboxItems.length,++ messages: inboxItems.reduce((n, i) => n + i.counts.messages, 0),++ },++ },++ });++ }));++// ---- masks -------------------------------------------------------------------------------router.post('/api/v1/masks', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {@@@ -67,6 +143,7 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {owner,label: typeof body.label === 'string' ? body.label.slice(0, 120) : null,purpose: typeof body.purpose === 'string' ? body.purpose.slice(0, 120) : null,++ expiresDays: body.expires_days,});return res.status(201).json({ mask });} catch (err) {@@@ -86,6 +163,12 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {return res.json({ mask });}));++ router.post('/api/v1/masks/:id/disable', 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 });++ }));++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.');nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++++/**++ * The API's CORS rule, the house one: the browser may read a response from any first-party origin the service++ * manifests declare (openvibe.network, openvibe.blog, …), and from this site itself. Nothing else — no wildcard, no++ * tenant subdomain, no `http://` origin. Every service repeats this small helper rather than adding a dependency;++ * the allow-list is the part that matters.++ *++ * The API authenticates a cross-origin caller with a Bearer token, never with a cookie. A browser does not send++ * `Access-Control-Allow-Credentials`, so a cross-origin `fetch` cannot carry the person's openvibe.email session++ * cookie; a cookie-authenticated write is checked for same-origin in http/principal.js.++ */++const contracts = require('openvibe-contracts');++++const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;++let cached = null;++++/** The https origins of every first-party domain the installed Contracts manifest names. */++function manifestOrigins() {++ if (cached) return cached;++ const out = new Set();++ for (const manifest of (contracts.services && contracts.services.manifests) || []) {++ for (const domainName of manifest.domains || []) {++ const host = String(domainName || '').trim().toLowerCase();++ if (HOST_RE.test(host)) out.add(`https://${host}`);++ }++ }++ cached = out;++ return out;++}++++/** An origin-only form of a configured URL, or null. */++function originOf(url) {++ try {++ const u = new URL(String(url || ''));++ return u.origin === 'null' ? null : u.origin;++ } catch {++ return null;++ }++}++++/**++ * opts.extraOrigins additional exact origins (tests and an operator's override; still no wildcard)++ * opts.baseUrl this site's origin, always allowed (the same-origin API calls need no CORS, but a page that++ * names its own origin explicitly is still first-party)++ */++function createCors({ extraOrigins = [], baseUrl = null } = {}) {++ const allowed = new Set(manifestOrigins());++ const own = originOf(baseUrl);++ if (own) allowed.add(own);++ for (const value of extraOrigins || []) {++ const origin = originOf(value);++ if (origin) allowed.add(origin);++ }++++ return function cors(req, res, next) {++ const origin = req.get('origin');++ if (origin && allowed.has(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.vary('Origin');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');++ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');++ res.set('Access-Control-Max-Age', '600');++ }++ if (req.method === 'OPTIONS') return res.status(204).end();++ return next();++ };++}++++module.exports = { createCors, manifestOrigins, originOf };@@@ -3,8 +3,11 @@/*** Who is calling /api/v1 — req.principal:*-- * { kind: 'user', subject: 'usr_…', role, username }-- * a person, with their Network session token as a Bearer.++ * { kind: 'user', subject: 'usr_…', role, username, viaSession }++ * a person, with their Network session token as a Bearer, or the openvibe.email session cookie set by++ * openvibe-sdk/sso after sign-in. A cookie-authenticated write must come from openvibe.email itself++ * (Sec-Fetch-Site same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor++ * create a mask, turn one off or spend their burner allowance.* { 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,@@@ -13,14 +16,15 @@* 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.++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the++ * session or 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 (openvibe-sdk's verifyUserToken enforces it).*/const { serviceAuth, http, ids } = require('openvibe-contracts');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const USER_SUB = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);// The capabilities Network may grant to an app that acts for a person. A person with their own token needs none.const CAPABILITIES = {@@@ -28,6 +32,15 @@ const CAPABILITIES = {read: 'mail.messages.read',};++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */++function sameOrigin(req, baseUrl) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin';++ const origin = req.get('origin');++ if (!origin) return false;++ return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;++}++function decodePayload(token) {const parts = String(token || '').split('.');if (parts.length !== 3) return null;@@@ -57,50 +70,81 @@ function capabilitiesOf(claims) {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;++ if (header.startsWith('Bearer ')) {++ 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: 'app',-- requester: requesterOfSub(claims.sub),-- subject: behalf,-- capabilities: capabilitiesOf(claims),-- claims,++ kind: 'user',++ subject: v.claims.subject_id,++ requester: `user:${v.claims.subject_id}`,++ capabilities: CAPABILITIES,++ role: roleClaim(v.claims),++ username: usernameClaim(v.claims),++ token,++ viaSession: false,},};}-- 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'] };++ // No Authorization header: openvibe-sdk/sso's optionalAuth has verified the session cookie, if one was++ // presented. A cookie that does not verify is anonymous; a Bearer header, present but bad, is never++ // downgraded to the cookie (the branch above returns the error).++ const viewer = req.user;++ if (viewer && ids.isSubjectId('user', viewer.subject_id)) {++ return {++ principal: {++ kind: 'user',++ subject: viewer.subject_id,++ requester: `user:${viewer.subject_id}`,++ capabilities: CAPABILITIES,++ role: roleClaim(viewer),++ username: usernameClaim(viewer),++ token: req.token,++ viaSession: true,++ },++ };}-- 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,-- },-- };++ return { principal: { kind: 'anonymous' } };}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 });++ // A write authenticated by the session cookie must come from this site. (A Bearer token has no such rule:++ // the token is the caller's, and a browser cannot read a cross-origin response without CORS approval.)++ if (r.principal.kind === 'user' && r.principal.viaSession && !SAFE_METHODS.has(req.method)++ && !sameOrigin(req, config.baseUrl)) {++ return http.sendProblem(res, 403, 'mail.cross_site', {++ detail: 'A signed-in request that changes something must come from openvibe.email itself.',++ ctx: req.ov,++ });++ }req.principal = r.principal;return next();}@@@ -151,4 +195,4 @@ function createPrincipal({ config, keys }) {return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };}--module.exports = { createPrincipal, CAPABILITIES, decodePayload };++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };@@@ -13,6 +13,7 @@const { sql } = require('openvibe-sdk/db');const { staleCounters } = require('../domain/limits');const { observe } = require('../domain/messages');++const { disableDueMasks } = require('../domain/masks');const LEASE_KEY = 'purge_lease';const LEASE_MS = 30_000;@@@ -84,7 +85,7 @@ async function purgeBurner({ db, stalwart, burner }) {* 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 report = { expired: 0, messagesDeleted: 0, masksDisabled: 0, consumed: 0, counters: 0, skipped: false };const held = await db.tx(async (t) => takeLease(t, { now: now() }));if (!held) {report.skipped = true;@@@ -111,6 +112,14 @@ async function runDue({ db, stalwart, events, now = () => Date.now(), log = cons}}++ // A mask with a date stops resolving on that date. It is the same due-based rule: only the rows whose++ // expires_at has passed are touched, and the mail server is told once (disableDueMasks does that).++ try {++ report.masksDisabled = await disableDueMasks({ db, stalwart, now });++ } catch (err) {++ (log.warn || log.log).call(log, `[mail] the due masks were not disabled: ${err && err.message}`);++ }++const waiting = await db.tx(async (t) => waitingBurners(t));for (const burner of waiting) {if (!stalwart) continue;@@@ -146,8 +155,8 @@ function startPurgeJob({ db, stalwart, events, intervalMs = 60_000, now = () =>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`);++ if (!report.skipped && (report.expired || report.masksDisabled || report.consumed)) {++ (log.info || log.log).call(log, `[mail] purge: ${report.expired} burners expired, ${report.masksDisabled} masks disabled, ${report.messagesDeleted} messages deleted`);}} catch (err) {(log.warn || log.log).call(log, `[mail] the purge job failed: ${err && err.message}`);nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++++/**++ * The Stalwart plan: fs is M1's default blob store, s3 stays available, and apply.js turns either plan into the++ * JMAP object Stalwart expects. These tests do not need the binary or a database.++ */++const test = require('node:test');++const assert = require('node:assert');++const fs = require('fs');++const path = require('path');++const { parse } = require('../deploy/stalwart/toml');++const { render } = require('../deploy/stalwart/render');++const { blobStoreFor } = require('../deploy/stalwart/apply');++++const CONFIG = parse(fs.readFileSync(path.join(__dirname, '..', 'deploy', 'stalwart', 'config.toml'), 'utf8'));++const BASE_ENV = {++ STALWART_DB_HOST: '127.0.0.1',++ STALWART_DB_NAME: 'ov_mail',++ STALWART_DB_USER: 'ov_mail',++ MAIL_BASE_DOMAIN: 'openvibe.email',++ MAIL_MASK_DOMAIN: 'mask.openvibe.email',++ BURNER_DOMAINS: 'burn1.openvibe.email',++};++++test('the shipped configuration defaults to local filesystem blobs under /var/lib/openvibe-stalwart/blobs', () => {++ assert.strictEqual(CONFIG.blob_store.kind, 'fs');++ assert.strictEqual(CONFIG.blob_store.path, '/var/lib/openvibe-stalwart/blobs');++++ const { plan } = render(CONFIG, BASE_ENV);++ assert.deepStrictEqual(plan.blob_store, {++ kind: 'fs',++ path: '/var/lib/openvibe-stalwart/blobs',++ depth: 2,++ });++});++++test('STALWART_BLOB_STORE=fs overrides a file that says s3, and STALWART_BLOB_PATH/DEPTH are read', () => {++ const config = { ...CONFIG, blob_store: { ...CONFIG.blob_store, kind: 's3' } };++ const { plan } = render(config, {++ ...BASE_ENV,++ STALWART_BLOB_STORE: 'fs',++ STALWART_BLOB_PATH: '/srv/mail/blobs',++ STALWART_BLOB_DEPTH: '3',++ });++ assert.deepStrictEqual(plan.blob_store, { kind: 'fs', path: '/srv/mail/blobs', depth: 3 });++});++++test('STALWART_BLOB_STORE=s3 keeps the existing B2/MinIO path', () => {++ const { plan } = render(CONFIG, {++ ...BASE_ENV,++ STALWART_BLOB_STORE: 's3',++ STALWART_BLOB_ENDPOINT: 'http://127.0.0.1:9000',++ STALWART_BLOB_BUCKET: 'ov-mail',++ STALWART_BLOB_REGION: 'us-east-1',++ });++ assert.strictEqual(plan.blob_store.kind, 's3');++ assert.strictEqual(plan.blob_store.endpoint, 'http://127.0.0.1:9000');++ assert.strictEqual(plan.blob_store.bucket, 'ov-mail');++ assert.strictEqual(plan.blob_store.accessKeyIdEnv, 'STALWART_BLOB_ACCESS_KEY_ID');++});++++test('an unknown blob store kind is refused at render time', () => {++ assert.throws(() => render(CONFIG, { ...BASE_ENV, STALWART_BLOB_STORE: 'ftp' }), /must be "fs" or "s3"/);++ assert.throws(() => render(CONFIG, { ...BASE_ENV, STALWART_BLOB_DEPTH: '9' }), /0 to 5/);++});++++test('apply.js builds the FileSystem object without asking for S3 keys', () => {++ assert.deepStrictEqual(blobStoreFor({ kind: 'fs', path: '/var/lib/openvibe-stalwart/blobs', depth: 2 }, {}), {++ '@type': 'FileSystem',++ path: '/var/lib/openvibe-stalwart/blobs',++ depth: 2,++ });++});++++test('apply.js builds the S3 object from the environment, and refuses a missing key', () => {++ const blob = {++ kind: 's3',++ endpoint: 'http://127.0.0.1:9000',++ bucket: 'ov-mail',++ region: 'us-east-1',++ accessKeyIdEnv: 'STALWART_BLOB_ACCESS_KEY_ID',++ secretKeyEnv: 'STALWART_BLOB_SECRET_KEY',++ timeoutMs: 30000,++ maxRetries: 3,++ verifyAfterWrite: true,++ };++ assert.throws(() => blobStoreFor(blob, {}), /STALWART_BLOB_ACCESS_KEY_ID/);++ const object = blobStoreFor(blob, { STALWART_BLOB_ACCESS_KEY_ID: 'key', STALWART_BLOB_SECRET_KEY: 'secret' });++ assert.strictEqual(object['@type'], 'S3');++ assert.strictEqual(object.accessKeyId.value, 'key');++ assert.strictEqual(object.secretKey.secret, 'secret');++ assert.strictEqual(object.bucket, 'ov-mail');++});nnew file mode 100644@@@ -0,0 +1,188 @@++'use strict';++++/**++ * The front door: CORS, the same-site session cookie, and the one listing the page reads.++ *++ * These are the rules that decide who may reach the API from a browser, and they are easy to get subtly wrong:++ * a Bearer token is fine from any first-party origin, a cookie is not; a cookie write must come from++ * openvibe.email itself; and /me/mail carries no message content.++ */++const test = require('node:test');++const assert = require('node:assert');++const contracts = require('openvibe-contracts');++const { boot } = require('./helpers/boot');++++const ALLOWED = 'https://openvibe.network';++const OTHER = 'https://example.invalid';++++test('CORS lets a first-party origin read the API, and refuses another', async (t) => {++ const s = await boot();++ t.after(() => s.close());++++ const allowed = await s.options('/api/v1/masks', {++ headers: {++ origin: ALLOWED,++ 'access-control-request-method': 'POST',++ 'access-control-request-headers': 'authorization, content-type',++ },++ });++ assert.strictEqual(allowed.status, 204);++ assert.strictEqual(allowed.headers.get('access-control-allow-origin'), ALLOWED);++ assert.match(allowed.headers.get('access-control-allow-headers'), /authorization/i);++ assert.strictEqual(allowed.headers.get('access-control-allow-credentials'), null,++ 'a cross-origin caller is authenticated by a Bearer token, never by the cookie');++++ const refused = await s.options('/api/v1/masks', { headers: { origin: OTHER } });++ assert.strictEqual(refused.status, 204);++ assert.strictEqual(refused.headers.get('access-control-allow-origin'), null);++++ const read = await s.get('/api/v1/masks', { headers: { origin: OTHER } });++ assert.strictEqual(read.status, 401);++ assert.strictEqual(read.headers.get('access-control-allow-origin'), null);++});++++test('a Bearer token is accepted together with a first-party Origin', 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,++ headers: { origin: ALLOWED },++ json: { purpose: 'shopping' },++ });++ assert.strictEqual(made.status, 201);++ assert.strictEqual(made.headers.get('access-control-allow-origin'), ALLOWED);++});++++test('the session cookie reads the person\'s mail, and a write must come from openvibe.email', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const cookie = s.network.userToken(alice);++++ const session = await s.get('/api/v1/masks', { cookie });++ assert.strictEqual(session.status, 200, 'the cookie is a session on openvibe.email');++++ const crossSite = await s.post('/api/v1/masks', {++ cookie,++ origin: OTHER,++ secFetchSite: 'cross-site',++ json: { purpose: 'stolen' },++ });++ assert.strictEqual(crossSite.status, 403);++ assert.strictEqual(crossSite.body.code, 'mail.cross_site');++++ const sameSite = await s.post('/api/v1/masks', {++ cookie,++ origin: 'https://openvibe.email',++ secFetchSite: 'same-origin',++ json: { purpose: 'mine' },++ });++ assert.strictEqual(sameSite.status, 201);++++ // The fetch metadata alone is enough, even when the Origin header is not sent (same-origin GET/POST in some++ // browsers); a cross-site one is still refused.++ const metadataOnly = await s.post('/api/v1/masks', {++ cookie,++ secFetchSite: 'same-origin',++ json: { purpose: 'mine too' },++ });++ assert.strictEqual(metadataOnly.status, 201);++});++++test('a bad Bearer token is not downgraded to the session cookie', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const cookie = s.network.userToken(alice);++++ const res = await s.get('/api/v1/masks', { cookie, bearer: 'not.a.token' });++ assert.strictEqual(res.status, 401);++});++++test('/me/mail lists all three kinds with state and counts, and the resources are the Contracts shapes', 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: { purpose: 'shopping' } })).body.mask;++ const burner = (await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;++ const inbox = (await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'weather' } })).body.agent_inbox;++++ s.mail.deliver({ mailboxAddress: burner.address, to: burner.address, subject: 'code', text: 'Your code is 123456' });++ assert.strictEqual((await s.get(`/api/v1/burners/${burner.id}/code`, { as: alice })).status, 200,++ 'reading the code observes the message for the count');++++ const mine = await s.get('/api/v1/me/mail', { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.body.masks.length, 1);++ assert.strictEqual(mine.body.burners.length, 1);++ assert.strictEqual(mine.body.agent_inboxes.length, 1);++ assert.strictEqual(mine.body.counts.masks.total, 1);++ assert.strictEqual(mine.body.counts.burners.live, 1);++ assert.strictEqual(mine.body.counts.agent_inboxes.total, 1);++ assert.strictEqual(mine.body.burners[0].counts.messages, 1);++++ assert.ok(contracts.validate('mail.mask@1', mine.body.masks[0].mask).valid, 'mask is mail.mask@1');++ assert.ok(contracts.validate('mail.burner@1', mine.body.burners[0].burner).valid, 'burner is mail.burner@1');++ assert.ok(contracts.validate('mail.agent-inbox@1', mine.body.agent_inboxes[0].agent_inbox).valid, 'inbox is mail.agent-inbox@1');++ assert.strictEqual(mine.body.masks[0].mask.owner, alice.subject);++++ assert.strictEqual(mine.body.masks[0].state, 'active');++ assert.strictEqual(mine.body.burners[0].state, 'live');++ assert.strictEqual(mine.body.agent_inboxes[0].state, 'active');++ assert.strictEqual(mine.body.masks[0].address, mine.body.masks[0].mask.address);++ assert.strictEqual(mine.body.masks[0].purpose, 'shopping');++ assert.strictEqual(mine.body.agent_inboxes[0].purpose, null);++ assert.ok(mine.body.masks[0].mask.id && mine.body.masks[0].mask.address);++ assert.strictEqual(mask.id, mine.body.masks[0].mask.id);++ assert.strictEqual(burner.id, mine.body.burners[0].burner.id);++ assert.strictEqual(inbox.id, mine.body.agent_inboxes[0].agent_inbox.id);++++ const text = JSON.stringify(mine.body);++ assert.ok(!text.includes('123456'), 'no code in the listing');++ assert.ok(!text.includes('Your code is'), 'no body in the listing');++});++++test('/me/mail needs a person', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const res = await s.get('/api/v1/me/mail');++ assert.strictEqual(res.status, 401);++});++++test('a mask can be given a life, and POST /masks/:id/disable turns it off', 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: { purpose: 'shop', expires_days: 7 } });++ assert.strictEqual(made.status, 201);++ assert.ok(contracts.validate('mail.mask@1', made.body.mask).valid, 'the create answer is still mail.mask@1');++++ const listed = await s.get('/api/v1/me/mail', { as: alice });++ const item = listed.body.masks[0];++ assert.ok(item.expires_at, 'the listing carries the expiry');++ const days = (new Date(item.expires_at).getTime() - Date.now()) / 86400000;++ assert.ok(days > 6.9 && days <= 7, 'the expiry is seven days away');++++ const off = await s.post(`/api/v1/masks/${made.body.mask.id}/disable`, { as: alice });++ assert.strictEqual(off.status, 200);++ assert.strictEqual(off.body.mask.enabled, false);++ assert.ok(s.mail.calls.invalidateCaches >= 1);++++ const crossed = await s.post(`/api/v1/masks/${made.body.mask.id}/disable`, { as: alice });++ assert.strictEqual(crossed.status, 404);++});++++test('expires_days is a whole number from 1 to 3650', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ for (const value of [0, -1, 1.5, 'soon', 3651]) {++ const res = await s.post('/api/v1/masks', { as: alice, json: { purpose: 'x', expires_days: value } });++ assert.strictEqual(res.status, 400, `expires_days ${value} is refused`);++ assert.strictEqual(res.body.code, 'mail.bad_expiry');++ }++});@@@ -65,6 +65,9 @@ async function boot(opts = {}) {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;++ if (o.cookie) headers.cookie = `ov_token=${o.cookie}`;++ if (o.origin) headers.origin = o.origin;++ if (o.secFetchSite) headers['sec-fetch-site'] = o.secFetchSite;let body;if (o.json !== undefined) {body = JSON.stringify(o.json);@@@ -88,6 +91,7 @@ async function boot(opts = {}) {get: (p, o) => call('GET', p, o),post: (p, o) => call('POST', p, o),del: (p, o) => call('DELETE', p, o),++ options: (p, o) => call('OPTIONS', p, o),close: async () => {await new Promise((r) => server.close(r));await network.close();nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++++/**++ * deploy/nginx/openvibe.email.conf is the public door. This test is the static guard: the three server_name lines,++ * the TLS files for the name, and a proxy list limited to the API and the Network sign-in routes (plus the two++ * machine answers). `/` is the placeholder and never reaches the service.++ */++const test = require('node:test');++const assert = require('node:assert');++const fs = require('fs');++const path = require('path');++++const FILE = path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.email.conf');++const CONF = fs.readFileSync(FILE, 'utf8');++++/** location → block body (the file has no nested braces inside a location). */++function locations() {++ const out = new Map();++ for (const match of CONF.matchAll(/location\s+(?:=\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {++ out.set(match[1], match[2]);++ }++ return out;++}++++test('openvibe.email has the three house server_name lines and the existing certificate', () => {++ assert.match(CONF, /server_name openvibe\.email www\.openvibe\.email;/);++ assert.match(CONF, /server_name www\.openvibe\.email;/);++ assert.match(CONF, /server_name openvibe\.email;/);++ assert.match(CONF, /ssl_certificate\s+\/etc\/letsencrypt\/live\/openvibe\.email\/fullchain\.pem;/);++ assert.match(CONF, /ssl_certificate_key\s+\/etc\/letsencrypt\/live\/openvibe\.email\/privkey\.pem;/);++ assert.match(CONF, /include snippets\/security-txt\.conf;/);++});++++test('only the API, the machine answers and the sign-in routes are proxied', () => {++ const blocks = locations();++ const proxied = [...blocks.entries()]++ .filter(([, body]) => /proxy_pass\s+http:\/\/127\.0\.0\.1:5250;/.test(body))++ .map(([name]) => name)++ .sort();++ assert.deepStrictEqual(proxied, ['/api/health', '/api/ready', '/api/v1/', '/auth/', '/auth/me'].sort());++++ // A proxy anywhere else (a generic /api/ or / catch-all) would expose more than the door above.++ assert.strictEqual((CONF.match(/proxy_pass\s+/g) || []).length, proxied.length,++ 'every proxy_pass belongs to one of the five locations');++});++++test('/ is the placeholder and unknown paths are not proxied', () => {++ const blocks = locations();++ const root = blocks.get('/');++ assert.ok(root, 'there is an exact / location');++ assert.match(root, /return 200 /);++ assert.doesNotMatch(root, /proxy_pass/);++ assert.match(blocks.get('/api/'), /return 404;/);++ assert.doesNotMatch(blocks.get('/api/'), /proxy_pass/);++ assert.match(CONF, /location ~\* \^\/\(metrics\|internal\)\(/);++});++++test('the API forwards the client address the way the house does behind Cloudflare', () => {++ assert.match(CONF, /proxy_set_header Host \$host;/);++ assert.match(CONF, /proxy_set_header X-Real-IP \$remote_addr;/);++ assert.match(CONF, /proxy_set_header X-Forwarded-For \$remote_addr;/);++ assert.match(CONF, /proxy_set_header CF-Connecting-IP \$remote_addr;/);++ assert.match(CONF, /proxy_set_header X-Forwarded-Proto \$scheme;/);++ assert.match(CONF, /cloudflare-realip\.conf/, 'the global Cloudflare real-IP include is documented');++ assert.match(CONF, /Strict-Transport-Security/);++ assert.match(CONF, /X-Content-Type-Options nosniff/);++});@@@ -48,3 +48,22 @@ test('a failed delete keeps the burner, so the next run deletes the mail', asyncassert.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);});++++test('a mask whose life has run out is disabled, and the mail server is told once', 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: { purpose: 'one purchase', expires_days: 1 } });++ assert.strictEqual(made.status, 201);++ await s.db.exec("UPDATE mail_masks SET expires_at = now() - interval '1 minute' WHERE id = $1", [made.body.mask.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.masksDisabled, 1);++ assert.ok(s.mail.calls.invalidateCaches > before, 'the mail server forgot the disabled address');++ assert.strictEqual(await s.db.value('SELECT enabled FROM mail_masks WHERE id = $1', [made.body.mask.id]), false);++++ const listed = await s.get('/api/v1/me/mail', { as: alice });++ assert.strictEqual(listed.body.masks[0].state, 'expired');++});@@@ -1,12 +1,14 @@'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.++ * Start the pinned Stalwart binary against the test PostgreSQL, 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.++ * Blobs go to a temporary local directory by default (M1's fs store); set STALWART_BLOB_STORE=s3 to run the same++ * acceptance test against MinIO. 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 only when the S3 store is selected.*/const fs = require('fs');const os = require('os');@@@ -40,6 +42,8 @@ function testEnv(overrides = {}) {STALWART_URL: 'http://127.0.0.1:18080',STALWART_ADMIN_USER: 'admin',STALWART_ADMIN_PASSWORD: 'ov-test-admin-password',++ STALWART_BLOB_STORE: process.env.STALWART_BLOB_STORE || 'fs',++ STALWART_BLOB_PATH: process.env.STALWART_BLOB_PATH || '',...overrides,};}@@@ -80,11 +84,18 @@ async function disableInboundThrottles(client) {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 blobStore = String(env.STALWART_BLOB_STORE || 'fs').toLowerCase();++ if (blobStore === 's3') {++ 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 s3 acceptance run needs the S3/MinIO target`);++ }}const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mail-stalwart-'));++ const blobDir = blobStore === 'fs'++ ? (env.STALWART_BLOB_PATH || fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mail-blobs-')))++ : null;++ if (blobDir) env = { ...env, STALWART_BLOB_PATH: blobDir };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'));@@@ -175,6 +186,7 @@ async function start({ env = testEnv(), binary = null } = {}) {return {child: childState.child,dir,++ blobDir,logs,url,smtpPort: Number(env.STALWART_SMTP_PORT),@@@ -184,6 +196,7 @@ async function start({ env = testEnv(), binary = null } = {}) {async stop() {await stopState(childState);fs.rmSync(dir, { recursive: true, force: true });++ if (blobDir) fs.rmSync(blobDir, { recursive: true, force: true });},};}@@@ -1,7 +1,8 @@'use strict';/**-- * The M1 acceptance test against the real Stalwart binary, PostgreSQL and MinIO.++ * The M1 acceptance test against the real Stalwart binary and PostgreSQL. Blobs go to a temporary local directory++ * (M1's fs store); with STALWART_BLOB_STORE=s3 the same run uses 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;@@@ -12,9 +13,11 @@*/const test = require('node:test');const assert = require('node:assert');++const fs = require('fs');const http = require('http');const { start, testEnv, waitFor, sleep } = require('./helpers/stalwart');++const { createClient } = require('../../deploy/stalwart/apply');const { ensureDatabase, resetSchema, urlFor } = require('./helpers/pg');const { ensureBucket, listKeys } = require('./helpers/s3');const { send, sendMany } = require('./helpers/smtp');@@@ -37,6 +40,36 @@ const { createApp } = require('../../server/app');const { createStalwartClient } = require('../../server/stalwart/client');const { runDue } = require('../../server/jobs/purge');++function blobFiles() {++ if (!stalwart || !stalwart.blobDir) return 0;++ let count = 0;++ for (const entry of fs.readdirSync(stalwart.blobDir, { recursive: true, withFileTypes: true })) {++ if (entry.isFile()) count += 1;++ }++ return count;++}++++/**++ * Ask the mail server to run its blob purge now. On its normal schedule it runs once a day, so the acceptance test++ * would otherwise see the message's blob for up to a day after the account is gone.++ */++async function purgeBlobs() {++ const client = createClient({++ url: stalwart.url,++ user: stalwart.env.STALWART_ADMIN_USER,++ password: stalwart.env.STALWART_ADMIN_PASSWORD,++ });++ await client.one('x:Task/set', {++ create: {++ purge: {++ '@type': 'StoreMaintenance',++ maintenanceType: 'purgeBlob',++ status: { '@type': 'Pending' },++ },++ },++ }, 'pb');++}++function captureLog() {const lines = [];const push = (...a) => lines.push(a.map((x) => String(x)).join(' '));@@@ -92,7 +125,7 @@ async function waitForAgentMessages(id, o = {}) {test.before(async () => {await ensureDatabase(env);await resetSchema(env);-- ensureBucket();++ if (String(env.STALWART_BLOB_STORE || 'fs').toLowerCase() === 's3') ensureBucket();stalwart = await start({ env });network = await startNetwork();@@@ -173,7 +206,12 @@ test('SMTP to a burner lands, the code endpoint returns it, and the log has no bassert.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');++ if (stalwart.env.STALWART_BLOB_STORE === 's3') {++ assert.ok(listKeys().length > 0, 'Stalwart wrote the message into the S3 blob store');++ } else {++ assert.ok(blobFiles() > 0, 'Stalwart wrote the message onto the local filesystem blob store');++ assert.match(stalwart.blobDir, /ov-mail-blobs-/);++ }});test('a burner made for one message stops accepting after the first', async () => {@@@ -252,10 +290,18 @@ test('after expiry the burner bounces and its mail is deleted', async () => {await send({ port: stalwart.smtpPort, to: burner.address, subject: 'code', text: 'Your code is 778899' });await waitForCode(burner.id, { as: alice });++ const blobsBefore = stalwart.env.STALWART_BLOB_STORE === 's3' ? listKeys().length : blobFiles();++ assert.ok(blobsBefore > 0, 'the message has a blob before the purge');++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 purgeBlobs();++ const blobCount = () => (stalwart.env.STALWART_BLOB_STORE === 's3' ? listKeys().length : blobFiles());++ await waitFor(async () => blobCount() < blobsBefore, { timeoutMs: 30_000, what: 'the blob purge' });++ assert.ok(blobCount() < blobsBefore, 'the blob purge removed the burner message blob');++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,@@@ -2,9 +2,10 @@'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.++ * The Stalwart acceptance run. It is separate from `npm test` because it needs the pinned binary and PostgreSQL;++ * M1's default blob store is the local filesystem, so no object store is needed. CI starts PostgreSQL and runs this++ * once with fs blobs and once with `STALWART_BLOB_STORE=s3` against MinIO. A workstation can use++ * `npm run test:stalwart` after fetching the binary and pointing the STALWART_* variables at its database.*/const fs = require('fs');const path = require('path');@@@ -17,8 +18,10 @@ function missing() {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);++ if (String(process.env.STALWART_BLOB_STORE || 'fs').toLowerCase() === 's3') {++ 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;}
@@ -23,6 +23,14 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -23,6 +23,14 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# OV_SESSION_AUDIENCE=openvibe.network
# OV_SESSION_AUDIENCE=openvibe.network
# MAIL_AUDIENCE=openvibe.mail
# MAIL_AUDIENCE=openvibe.mail
+# Sign-in on openvibe.email (openvibe-sdk/sso, OAuth2 + PKCE). The client must be registered in the Network with
+# redirect https://openvibe.email/auth/callback. COOKIE_SECURE defaults to true in production.
+OV_OAUTH_CLIENT_ID=mail
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.email/auth/callback
+# OV_OAUTH_SCOPE=profile
+# COOKIE_SECURE=true
+
# The addresses. Masks live on their own domain, burners rotate over the subdomains below, and an agent's inbox is
# 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.
# <agent>.<user>@<base domain>. A new burner domain needs no code: add it here and restart.
MAIL_BASE_DOMAIN=openvibe.email
MAIL_BASE_DOMAIN=openvibe.email
@@ -41,6 +49,12 @@ STALWART_DB_PORT=5432
@@ -41,6 +49,12 @@ STALWART_DB_PORT=5432
STALWART_DB_NAME=ov_mail
STALWART_DB_NAME=ov_mail
STALWART_DB_USER=
STALWART_DB_USER=
STALWART_DB_PASSWORD=
STALWART_DB_PASSWORD=
+
+# Blobs: local disk by default (M1). STALWART_BLOB_STORE=s3 turns on the B2/MinIO path below; with fs only
+# STALWART_BLOB_PATH and STALWART_BLOB_DEPTH matter. The depth must not change after blobs are written.
+STALWART_BLOB_STORE=fs
+STALWART_BLOB_PATH=/var/lib/openvibe-stalwart/blobs
+STALWART_BLOB_DEPTH=2
STALWART_BLOB_ENDPOINT=
STALWART_BLOB_ENDPOINT=
STALWART_BLOB_BUCKET=
STALWART_BLOB_BUCKET=
STALWART_BLOB_ACCESS_KEY_ID=
STALWART_BLOB_ACCESS_KEY_ID=
@@ -11,7 +11,52 @@ permissions:
@@ -11,7 +11,52 @@ permissions:
jobs:
jobs:
test:
test:
- name: unit and Stalwart acceptance
+ name: unit and Stalwart acceptance (fs blobs)
+ 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
+ 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 + fs blobs + 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_STORE: fs
+ run: npm run test:stalwart
+
+ # The second half of the blob-store promise: the same acceptance run against an S3-compatible store (MinIO),
+ # which is what B2 is in production. It runs beside the fs job, so it costs one runner and no serial time.
+ test-s3:
+ name: Stalwart acceptance (s3 blobs)
runs-on: ubuntu-24.04
runs-on: ubuntu-24.04
services:
services:
postgres:
postgres:
@@ -46,12 +91,6 @@ jobs:
@@ -46,12 +91,6 @@ jobs:
node-version: 22.22.1
node-version: 22.22.1
cache: npm
cache: npm
- run: npm ci --no-audit --no-fund
- 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)
- name: download the pinned Stalwart (sha256 verified)
run: node scripts/fetch-stalwart.js
run: node scripts/fetch-stalwart.js
- name: Stalwart acceptance (PostgreSQL + MinIO + real SMTP)
- name: Stalwart acceptance (PostgreSQL + MinIO + real SMTP)
@@ -62,6 +101,7 @@ jobs:
@@ -62,6 +101,7 @@ jobs:
STALWART_DB_USER: ov
STALWART_DB_USER: ov
STALWART_DB_PASSWORD: ov-ci-password
STALWART_DB_PASSWORD: ov-ci-password
STALWART_TEST_DB: ov_mail_test
STALWART_TEST_DB: ov_mail_test
+ STALWART_BLOB_STORE: s3
STALWART_BLOB_ENDPOINT: http://127.0.0.1:9000
STALWART_BLOB_ENDPOINT: http://127.0.0.1:9000
STALWART_BLOB_BUCKET: ov-mail
STALWART_BLOB_BUCKET: ov-mail
STALWART_BLOB_REGION: us-east-1
STALWART_BLOB_REGION: us-east-1
@@ -14,6 +14,7 @@ the OpenVibe Seven Domains plan (2026-11-07) §8.
@@ -14,6 +14,7 @@ the OpenVibe Seven Domains plan (2026-11-07) §8.
| **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. |
| **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. |
| **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. |
| **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. |
+| **Blobs** | Message blobs live on the mail server's local disk by default (`/var/lib/openvibe-stalwart/blobs`, `STALWART_BLOB_STORE=fs`). S3-compatible storage (B2 in production, MinIO in CI) stays available with `STALWART_BLOB_STORE=s3`. |
The service stores only the addresses and metadata: message bodies are read from Stalwart over loopback JMAP when a
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
code is asked for, and are never written to a log, an event or the database. A burner's account is read with no
@@ -22,16 +23,21 @@ recipient filtering at all; a mask/agent inbox in the shared person mailbox is m
@@ -22,16 +23,21 @@ recipient filtering at all; a mask/agent inbox in the shared person mailbox is m
## API
## 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.
+All routes are under `/api/v1` and take an OpenVibe.Network token (`Authorization: Bearer …`), or the person's
+same-site session cookie on openvibe.email (set by `openvibe-sdk/sso` after sign-in; `/auth/*` is the sign-in
+handshake). A cross-origin browser may send a Bearer token from any first-party origin in the Contracts manifests
+(CORS); a cookie-authenticated **write** must come from openvibe.email itself (`Sec-Fetch-Site`/`Origin`). 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 |
| Method | Route | Who |
|---|---|---|
|---|---|---|
-| `POST` | `/masks` | a person; `{ label, purpose }` |
+| `GET` | `/me/mail` | a person; masks, burners and agent inboxes with state, expiry and counts (see below) |
+| `POST` | `/masks` | a person; `{ purpose, expires_days?, label? }` |
| `GET` | `/masks` | a person |
| `GET` | `/masks` | a person |
| `GET` | `/masks/:id` | the owner; anyone else gets 404 |
| `GET` | `/masks/:id` | the owner; anyone else gets 404 |
-| `DELETE` | `/masks/:id` | the owner; turns the mask off |
+| `POST` | `/masks/:id/disable` | the owner; turns the mask off |
+| `DELETE` | `/masks/:id` | the same, for a DELETE-shaped client |
| `POST` | `/burners` | a person, or nobody (`{ expires: "10m" \| "1h" \| "1d" \| "7d", first_message_only }`) |
| `POST` | `/burners` | a person, or nobody (`{ expires: "10m" \| "1h" \| "1d" \| "7d", first_message_only }`) |
| `GET` | `/burners` | a person |
| `GET` | `/burners` | a person |
| `GET` | `/burners/:id/messages` | the owner, or the holder of the returned `x-mail-burner-token` |
| `GET` | `/burners/:id/messages` | the owner, or the holder of the returned `x-mail-burner-token` |
@@ -46,6 +52,12 @@ API returns an array of `{ id, from, subject, received_at }` and nothing else: n
@@ -46,6 +52,12 @@ API returns an array of `{ id, from, subject, received_at }` and nothing else: n
memory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the liveness
memory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the liveness
answer.
answer.
+`GET /api/v1/me/mail` is the one call the page makes: it returns `masks`, `burners` and `agent_inboxes`, where each
+item carries the Contracts resource (`mask`, `burner`, `agent_inbox`) exactly, beside the listing fields `address`,
+`purpose`, `state`, a mask's `expires_at`, and `counts.messages` (the messages the service has already seen for that
+address), plus totals under `counts`. No subject, sender, body or code is in it. A mask made with `expires_days` turns itself off when the date
+arrives; the purge job disables the due rows and the directory stops resolving them.
+
## Events
## Events
The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:
The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:
@@ -56,9 +68,10 @@ The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:
@@ -56,9 +68,10 @@ The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured:
## Contracts
## 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.
+`mail.mask@1`, `mail.burner@1`, `mail.agent-inbox@1` and `mail.message-summary@1` are in
+[OpenVibe.Contracts](https://github.com/OpenVibers/OpenVibe.Contracts) v0.329.0, which this service pins. The
+`/me/mail` listing is a projection above them (state, expiry, counts) — the Contracts resources do not grow extra
+fields, and no Contracts release is made from this repository.
## Stalwart
## Stalwart
@@ -72,8 +85,8 @@ node deploy/stalwart/apply.js # reconciles the plan over loopback JMAP
@@ -72,8 +85,8 @@ 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
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
+filesystem blob store (or S3 when `STALWART_BLOB_STORE=s3`), 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.
directory-created account the `User` role — without it Stalwart creates the account and then refuses its own mail.
## Tests
## Tests
@@ -81,7 +94,8 @@ directory-created account the `User` role — without it Stalwart creates the ac
@@ -81,7 +94,8 @@ directory-created account the `User` role — without it Stalwart creates the ac
```sh
```sh
npm test # unit: PGlite, an in-process Network and an in-memory mail server
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:pg # the same through the PostgreSQL container
-npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL + MinIO
+npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL, fs blobs by default (MinIO with
+ # STALWART_BLOB_STORE=s3)
```
```
The acceptance run (also `test/stalwart/integration.test.js` in CI) starts the pinned binary in recovery mode so
The acceptance run (also `test/stalwart/integration.test.js` in CI) starts the pinned binary in recovery mode so
@@ -97,7 +111,9 @@ checks:
@@ -97,7 +111,9 @@ checks:
- an expired burner's mail is deleted, its account is removed and the address bounces;
- an expired burner's mail is deleted, its account is removed and the address bounces;
- mail to a disabled mask bounces;
- mail to a disabled mask bounces;
- another person gets 404 for a mask, burner and agent inbox;
- 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.
+- a ten-minute burner works without an account and is read with its token;
+- the message blob is on the local disk with `STALWART_BLOB_STORE=fs` (and the same run passes with `s3` in CI);
+- the CORS allow-list, the same-site cookie write rule and the `/me/mail` listing.
## Layout
## Layout
@@ -106,6 +122,7 @@ server/ the API, domains, Stalwart client, events and the due-based p
@@ -106,6 +122,7 @@ server/ the API, domains, Stalwart client, events and the due-based p
migrations/ NNNN_*.sql (expand-only), including the directory view Stalwart reads
migrations/ NNNN_*.sql (expand-only), including the directory view Stalwart reads
deploy/stalwart/ config.toml, VERSION, render.js, apply.js
deploy/stalwart/ config.toml, VERSION, render.js, apply.js
deploy/systemd/ the memory-capped Stalwart slice and both units
deploy/systemd/ the memory-capped Stalwart slice and both units
+deploy/nginx/ openvibe.email.conf — the public door (API, health, ready, /auth)
deploy/init-db.js apply migrations before systemd starts the service
deploy/init-db.js apply migrations before systemd starts the service
test/ unit tests and the real-Stalwart acceptance test
test/ unit tests and the real-Stalwart acceptance test
docs/ INSTALL.md and DNS.md
docs/ INSTALL.md and DNS.md
@@ -3,17 +3,12 @@
@@ -3,17 +3,12 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "M1",
"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",
+ "plan": "OpenVibe Seven Domains Plan (2026-11-07) \u00a78, 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), local filesystem blobs (S3 optional)",
"port": 5250,
"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"
- ],
+ "domain": "openvibe.email; receive-only MX. The API is public through nginx (deploy/nginx/openvibe.email.conf); no UI yet.",
+ "contracts": "openvibe-contracts v0.329.0",
+ "contractsProposed": [],
"sdk": "openvibe-sdk v0.97.1",
"sdk": "openvibe-sdk v0.97.1",
"shared": "openvibe-shared v4.135.0",
"shared": "openvibe-shared v4.135.0",
"stalwart": {
"stalwart": {
@@ -21,7 +16,7 @@
@@ -21,7 +16,7 @@
"asset": "stalwart-x86_64-unknown-linux-gnu.tar.gz",
"asset": "stalwart-x86_64-unknown-linux-gnu.tar.gz",
"sha256": "8ae481ff410cd09dd53d7f49f528dcad35d90b4b2ffb0c49c9ae23aaf43c4831",
"sha256": "8ae481ff410cd09dd53d7f49f528dcad35d90b4b2ffb0c49c9ae23aaf43c4831",
"dataStore": "PostgreSQL (database ov_mail)",
"dataStore": "PostgreSQL (database ov_mail)",
- "blobStore": "S3-compatible (Backblaze B2 in production, MinIO in CI)",
+ "blobStore": "local filesystem /var/lib/openvibe-stalwart/blobs by default (STALWART_BLOB_STORE=fs); S3-compatible optional (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",
"listeners": "SMTP 25 inbound; JMAP/management 8080 and IMAP 143 on loopback; no submission, POP3, Sieve or HTTPS listener",
"spamFilter": "built-in, on",
"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"
"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"
@@ -51,7 +46,8 @@
@@ -51,7 +46,8 @@
"tests": {
"tests": {
"unit": "node test/run.js (PGlite, in-process Network and mail server)",
"unit": "node test/run.js (PGlite, in-process Network and mail server)",
"postgres": "MAIL_TEST_STORE=pg node test/run.js",
"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)"
+ "acceptance": "node test/stalwart/run.js (pinned Stalwart + PostgreSQL, fs blobs by default and one S3 CI run; 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, blob on disk)",
+ "frontdoor": "node test/frontdoor.test.js (CORS allow-list, same-site cookie write rule, /me/mail)"
},
},
"deployed": false,
"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.",
"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.",
@@ -59,6 +55,21 @@
@@ -59,6 +55,21 @@
"the API is behind OpenVibe.Network auth; typed tokens are refused",
"the API is behind OpenVibe.Network auth; typed tokens are refused",
"someone else's address is 404, never 403",
"someone else's address is 404, never 403",
"Stalwart runs in its own systemd slice: MemoryMax=1G, CPUQuota=100%, NoNewPrivileges, ProtectSystem=strict",
"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"
- ]
+ "the mail server has no password-authenticated listener and no relay: domains are allowRelaying=false and the submission/POP3/Sieve/HTTPS listeners are removed",
+ "a cookie-authenticated write must come from openvibe.email (Sec-Fetch-Site/Origin)",
+ "CORS is an exact allow-list from the Contracts manifests; no wildcard and no credentialed cross-origin request"
+ ],
+ "publicApi": {
+ "base": "https://openvibe.email",
+ "paths": [
+ "/api/v1/",
+ "/api/health",
+ "/api/ready",
+ "/auth/"
+ ],
+ "placeholder": "/ answers a placeholder page until the UI lands",
+ "nginx": "deploy/nginx/openvibe.email.conf",
+ "cors": "Bearer from any first-party origin in the Contracts manifests; cookie writes only same-origin on openvibe.email",
+ "trustProxy": 2
+ }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.email — Nginx configuration (REFERENCE)
+##
+## Installed at /etc/nginx/sites-available/openvibe.email.conf and linked in sites-enabled; the parked-domains.conf
+## block for openvibe.email and www.openvibe.email must be removed first, or this vhost never wins the name.
+##
+## The Mail Node service listens on 127.0.0.1:5250 and serves only JSON. This vhost terminates TLS and is the
+## public door:
+##
+## / a placeholder until the openvibe.email page lands (not proxied)
+## /api/v1/ the API; the app enforces Network auth, CORS and the write rules
+## /api/health, /api/ready the machine answers
+## /auth/ sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback,
+## /auth/me, /auth/logout. The session cookie is host-only on openvibe.email.
+##
+## Everything else answers 404. The API body cap is small: a mask label or a purpose, never a message. No nginx
+## rate limit is added: the app's 240/minute per address (MAIL_API_RATE_LIMIT_PER_MIN) and the burner counters are
+## the limits, and they must see the real client address.
+##
+## Cloudflare: the host's /etc/nginx/conf.d/cloudflare-realip.conf is included from nginx.conf's http block for
+## every vhost; it restores the person's address from CF-Connecting-IP. That is why this file, like the other
+## house vhosts, does not repeat the set_real_ip_from list, and why X-Forwarded-For is set to $remote_addr rather
+## than $proxy_add_x_forwarded_for. The app runs TRUST_PROXY=2 (Cloudflare + nginx).
+## ═══════════════════════════════════════════════════════════════
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.email www.openvibe.email;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.email$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.email;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.email/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.email/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.email$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.email;
+ include snippets/security-txt.conf; # /.well-known/security.txt
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.email/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.email/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.email.access.log;
+ error_log /var/log/nginx/openvibe.email.error.log;
+
+ # The API's JSON body is a label or a purpose; the mail itself never comes through here.
+ client_max_body_size 64k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options DENY always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types application/json application/problem+json text/html text/plain;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── the placeholder until Opus's page lands ─────────────
+ # Deliberately not proxied: only the API and the sign-in routes reach the service.
+ location = / {
+ default_type text/html;
+ return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>OpenVibe.Mail</title><style>:root{color-scheme:dark light}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0a0f1c;color:#e6edf7;font:16px/1.6 system-ui,sans-serif}main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}p{color:#96a7c2}</style></head><body><main><h1>OpenVibe.Mail</h1><p>The mailbox page is on its way. The API is live at <code>/api/v1/</code>.</p></main></body></html>';
+ }
+
+ # ── the machine answers ─────────────────────────────────
+ location = /api/health {
+ proxy_pass http://127.0.0.1:5250;
+ }
+
+ location = /api/ready {
+ proxy_pass http://127.0.0.1:5250;
+ }
+
+ # ── the API ─────────────────────────────────────────────
+ # The app answers the CORS preflight and sets the headers; nginx must not add any of its own.
+ location /api/v1/ {
+ proxy_pass http://127.0.0.1:5250;
+ }
+
+ # ── Network SSO (openvibe-sdk/sso) ──────────────────────
+ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
+ location = /auth/me {
+ proxy_pass http://127.0.0.1:5250;
+ }
+
+ location /auth/ {
+ proxy_pass http://127.0.0.1:5250;
+ }
+
+ # ── everything else ─────────────────────────────────────
+ # No generic /api/ proxy: only the paths above are the public API.
+ location /api/ { return 404; }
+ location ~* ^/(metrics|internal)(/|$) { return 404; }
+ location / { return 404; }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
@@ -9,7 +9,8 @@
@@ -9,7 +9,8 @@
* authentication points at that directory
* authentication points at that directory
* domains the base, mask and burner domains, none allowed to relay
* domains the base, mask and burner domains, none allowed to relay
* system the SMTP hostname and the default domain
* 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
+ * blob store local disk by default (M1), or S3-compatible (B2 in production); the S3 keys are read from the
+ * environment here, never written
* spam the built-in filter, on
* spam the built-in filter, on
* listeners SMTP on 25; JMAP/management and IMAP on loopback; every submission/POP3/Sieve/HTTPS listener gone
* listeners SMTP on 25; JMAP/management and IMAP on loopback; every submission/POP3/Sieve/HTTPS listener gone
*
*
@@ -71,6 +72,41 @@ function setProblems(label, result) {
@@ -71,6 +72,41 @@ function setProblems(label, result) {
return problems;
return problems;
}
}
+/**
+ * The BlobStore JMAP object for the plan. fs writes into a directory on this host; s3 reads its two keys from the
+ * environment (never from the plan or a rendered file) and points them at the S3-compatible endpoint.
+ */
+function blobStoreFor(blob, env = process.env) {
+ const kind = String((blob && blob.kind) || 'fs').toLowerCase();
+ if (kind === 'fs') {
+ if (!blob.path) throw new Error('the fs blob store needs a path');
+ return {
+ '@type': 'FileSystem',
+ path: blob.path,
+ depth: Number.isInteger(blob.depth) ? blob.depth : 2,
+ };
+ }
+ if (kind !== 's3') throw new Error(`unknown blob store kind: ${kind}`);
+ 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}`);
+ }
+ return {
+ '@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,
+ };
+}
+
async function apply(plan, client, env = process.env) {
async function apply(plan, client, env = process.env) {
const problems = [];
const problems = [];
@@ -169,25 +205,7 @@ async function apply(plan, client, env = process.env) {
@@ -169,25 +205,7 @@ async function apply(plan, client, env = process.env) {
problems.push(...setProblems('system', system));
problems.push(...setProblems('system', system));
// ---- blob store ------------------------------------------------------------------------
// ---- 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 blobStore = blobStoreFor(plan.blob_store, env);
const blobResult = await client.one('x:BlobStore/set', { update: { singleton: blobStore } }, 'b');
const blobResult = await client.one('x:BlobStore/set', { update: { singleton: blobStore } }, 'b');
problems.push(...setProblems('blob store', blobResult));
problems.push(...setProblems('blob store', blobResult));
@@ -286,4 +304,4 @@ if (require.main === module) {
@@ -286,4 +304,4 @@ if (require.main === module) {
});
});
}
}
-module.exports = { apply, createClient };
+module.exports = { apply, createClient, blobStoreFor };
@@ -33,8 +33,17 @@ ssl = false
@@ -33,8 +33,17 @@ ssl = false
pool_max_connections = 20
pool_max_connections = 20
[blob_store]
[blob_store]
-# B2 in production (an S3-compatible endpoint), MinIO in CI. The endpoint is a custom S3 region to Stalwart.
-kind = "s3"
+# M1's default is the mail server's own disk: the message blobs live beside the database, under the service's
+# StateDirectory (the systemd unit already allows writes there). STALWART_BLOB_PATH overrides the directory and
+# STALWART_BLOB_DEPTH the number of hash levels; the depth must not change after blobs have been written.
+kind = "fs"
+path = "/var/lib/openvibe-stalwart/blobs"
+depth = 2
+
+# S3-compatible storage (Backblaze B2 in production, MinIO in CI) stays available:
+# STALWART_BLOB_STORE=s3
+# The endpoint is a custom S3 region to Stalwart, and the keys are the *names* of environment variables that
+# apply.js resolves in memory. These values are used only when STALWART_BLOB_STORE=s3.
endpoint = "https://s3.eu-central-003.backblazeb2.com"
endpoint = "https://s3.eu-central-003.backblazeb2.com"
bucket = "openvibe-mail"
bucket = "openvibe-mail"
region = "eu-central-003"
region = "eu-central-003"
@@ -8,7 +8,9 @@
@@ -8,7 +8,9 @@
* plan.json the settings apply.js reconciles over loopback JMAP
* 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
* 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.
+ * the *names* of environment variables that apply.js resolves in memory. M1's default blob store is the local
+ * filesystem (STALWART_BLOB_STORE=fs); STALWART_BLOB_STORE=s3 keeps the B2/MinIO path available for a host that
+ * wants blobs off the mail server's disk.
*
*
* node deploy/stalwart/render.js [--config PATH] [--out DIR]
* node deploy/stalwart/render.js [--config PATH] [--out DIR]
*/
*/
@@ -23,46 +25,43 @@ function arg(name, fallback) {
@@ -23,46 +25,43 @@ function arg(name, fallback) {
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
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) {
function required(value, what) {
if (!value) throw new Error(`${what} is empty (see .env.example)`);
if (!value) throw new Error(`${what} is empty (see .env.example)`);
return value;
return value;
}
}
-function render(config) {
+function render(config, env = process.env) {
+ const getEnv = (name, fallback) => {
+ const v = env[name];
+ return v === undefined || v === '' ? fallback : v;
+ };
+ const getString = (name, fallback) => String(getEnv(name, fallback)).trim();
+ const getInt = (name, fallback) => {
+ const v = getEnv(name, fallback);
+ 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;
+ };
+ const getBool = (name, fallback) => {
+ const v = getEnv(name, fallback);
+ if (v === undefined || v === '') return fallback;
+ return /^(1|true|yes|on)$/i.test(String(v).trim());
+ };
+ const getList = (name, fallback) => {
+ const v = getEnv(name, fallback);
+ if (v === undefined || v === '') return fallback;
+ return String(v).split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
+ };
const db = config.data_store || {};
const db = config.data_store || {};
if (String(db.kind || '').toLowerCase() !== 'postgresql') {
if (String(db.kind || '').toLowerCase() !== 'postgresql') {
throw new Error('config.toml: data_store.kind must be "postgresql" (M1 stores mail in 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');
+ const host = getString('STALWART_DB_HOST', db.host);
+ const port = getInt('STALWART_DB_PORT', db.port);
+ const database = getString('STALWART_DB_NAME', db.database);
+ const user = getString('STALWART_DB_USER', db.user);
+ const passwordEnv = getString('STALWART_DB_PASSWORD_ENV', db.password_env || 'STALWART_DB_PASSWORD');
required(host, 'STALWART_DB_HOST');
required(host, 'STALWART_DB_HOST');
required(database, 'STALWART_DB_NAME');
required(database, 'STALWART_DB_NAME');
required(user, 'STALWART_DB_USER');
required(user, 'STALWART_DB_USER');
@@ -74,41 +73,58 @@ function render(config) {
@@ -74,41 +73,58 @@ function render(config) {
database,
database,
authUsername: user,
authUsername: user,
authSecret: { '@type': 'EnvironmentVariable', variableName: passwordEnv },
authSecret: { '@type': 'EnvironmentVariable', variableName: passwordEnv },
- useTls: envBool('STALWART_DB_SSL', Boolean(db.ssl)),
+ useTls: getBool('STALWART_DB_SSL', Boolean(db.ssl)),
allowInvalidCerts: false,
allowInvalidCerts: false,
- poolMaxConnections: envInt('STALWART_DB_POOL_MAX', db.pool_max_connections || 20),
+ poolMaxConnections: getInt('STALWART_DB_POOL_MAX', db.pool_max_connections || 20),
};
};
const blob = config.blob_store || {};
const blob = config.blob_store || {};
const domains = config.domains || {};
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 || []);
+ const base = getString('MAIL_BASE_DOMAIN', domains.base);
+ const mask = getString('MAIL_MASK_DOMAIN', domains.mask);
+ const burners = getList('BURNER_DOMAINS', domains.burners || []);
if (!burners.length) throw new Error('BURNER_DOMAINS is empty: a burner needs a domain to live on');
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 smtpBind = getString('STALWART_SMTP_BIND', config.listeners.smtp_bind);
+ const smtpPort = getInt('STALWART_SMTP_PORT', config.listeners.smtp_port);
+ const httpBind = getString('STALWART_HTTP_BIND', config.listeners.http_bind);
+ const httpPort = getInt('STALWART_HTTP_PORT', config.listeners.http_port);
+ const imapBind = getString('STALWART_IMAP_BIND', config.listeners.imap_bind);
+ const imapPort = getInt('STALWART_IMAP_PORT', config.listeners.imap_port);
+
+ // M1 defaults to blobs on the mail server's own disk. fs is the new default; s3 stays available (B2 in
+ // production, MinIO in CI) with the existing variables.
+ const blobKind = String(getString('STALWART_BLOB_STORE', blob.kind || 'fs')).toLowerCase();
+ if (blobKind !== 'fs' && blobKind !== 's3') {
+ throw new Error('config.toml: blob_store.kind / STALWART_BLOB_STORE must be "fs" or "s3"');
+ }
+ let blobStore;
+ if (blobKind === 'fs') {
+ blobStore = {
+ kind: 'fs',
+ path: getString('STALWART_BLOB_PATH', blob.path || '/var/lib/openvibe-stalwart/blobs'),
+ depth: getInt('STALWART_BLOB_DEPTH', blob.depth || 2),
+ };
+ required(blobStore.path, 'STALWART_BLOB_PATH');
+ if (blobStore.depth < 0 || blobStore.depth > 5) throw new Error('STALWART_BLOB_DEPTH must be from 0 to 5');
+ } else {
+ const s3 = {
+ kind: 's3',
+ endpoint: getString('STALWART_BLOB_ENDPOINT', blob.endpoint),
+ bucket: getString('STALWART_BLOB_BUCKET', blob.bucket),
+ region: getString('STALWART_BLOB_REGION', blob.region),
+ accessKeyIdEnv: getString('STALWART_BLOB_ACCESS_KEY_ID_ENV', blob.access_key_id_env || 'STALWART_BLOB_ACCESS_KEY_ID'),
+ secretKeyEnv: getString('STALWART_BLOB_SECRET_KEY_ENV', blob.secret_key_env || 'STALWART_BLOB_SECRET_KEY'),
+ allowInvalidCerts: getBool('STALWART_BLOB_ALLOW_INVALID_CERTS', Boolean(blob.allow_invalid_certs)),
+ verifyAfterWrite: getBool('STALWART_BLOB_VERIFY_AFTER_WRITE', blob.verify_after_write !== false),
+ timeoutMs: getInt('STALWART_BLOB_TIMEOUT_MS', blob.timeout_ms || 30000),
+ maxRetries: getInt('STALWART_BLOB_MAX_RETRIES', blob.max_retries || 3),
+ keyPrefix: getString('STALWART_BLOB_KEY_PREFIX', blob.key_prefix || '') || null,
+ };
+ required(s3.endpoint, 'STALWART_BLOB_ENDPOINT');
+ required(s3.bucket, 'STALWART_BLOB_BUCKET');
+ blobStore = s3;
+ }
const directory = config.directory || {};
const directory = config.directory || {};
const spam = config.spam || {};
const spam = config.spam || {};
@@ -119,7 +135,7 @@ function render(config) {
@@ -119,7 +135,7 @@ function render(config) {
generated_by: 'deploy/stalwart/render.js',
generated_by: 'deploy/stalwart/render.js',
version: 1,
version: 1,
server: {
server: {
- default_hostname: envString('STALWART_DEFAULT_HOSTNAME', config.server.default_hostname),
+ default_hostname: getString('STALWART_DEFAULT_HOSTNAME', config.server.default_hostname),
},
},
domains: {
domains: {
base,
base,
@@ -136,8 +152,8 @@ function render(config) {
@@ -136,8 +152,8 @@ function render(config) {
},
},
blob_store: blobStore,
blob_store: blobStore,
spam: {
spam: {
- enabled: envBool('STALWART_SPAM_ENABLED', spam.enabled !== false),
- score_spam: Number(envString('STALWART_SPAM_SCORE', spam.score_spam || 5.0)),
+ enabled: getBool('STALWART_SPAM_ENABLED', spam.enabled !== false),
+ score_spam: Number(getString('STALWART_SPAM_SCORE', spam.score_spam || 5.0)),
},
},
listeners: [
listeners: [
{ name: 'smtp', protocol: 'smtp', bind: [`${smtpBind}:${smtpPort}`], use_tls: true, implicit_tls: false },
{ name: 'smtp', protocol: 'smtp', bind: [`${smtpBind}:${smtpPort}`], use_tls: true, implicit_tls: false },
@@ -10,8 +10,11 @@ not collide with the plan, then the plan's listeners, then the Node service.
@@ -10,8 +10,11 @@ not collide with the plan, then the plan's listeners, then the Node service.
- The host's public IPv4 (and IPv6 if it has one), with inbound TCP 25 allowed and **587/465 closed**.
- 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).
- 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).
+- A writable local directory for message blobs, `/var/lib/openvibe-stalwart/blobs` by default (M1). A Backblaze
+ B2 bucket is optional: set `STALWART_BLOB_STORE=s3` to keep blobs off the mail server's disk.
- Node 22 on the host.
- Node 22 on the host.
+- An OAuth client in OpenVibe.Network for `mail`: redirect `https://openvibe.email/auth/callback` (the Network
+ seeds first-party clients in its database; this one is added with the host install, not by this repository).
- The pinned Stalwart binary, see [`deploy/stalwart/VERSION`](../deploy/stalwart/VERSION).
- The pinned Stalwart binary, see [`deploy/stalwart/VERSION`](../deploy/stalwart/VERSION).
Create the service user once:
Create the service user once:
@@ -54,9 +57,16 @@ the service's migrations create `mail_*` and the directory view. They share the
@@ -54,9 +57,16 @@ the service's migrations create `mail_*` and the directory view. They share the
## 2. Blob store
## 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.
+**M1 default: local disk.** Stalwart writes message blobs under `/var/lib/openvibe-stalwart/blobs` (the unit's
+`StateDirectory=openvibe-stalwart` already owns and writably mounts that tree). Nothing else is needed. The depth
+(`STALWART_BLOB_DEPTH`, default 2) decides the hash directory layout and **must not change** once blobs exist: the
+server would look in the wrong directories and stop finding old messages.
+
+**Optional: Backblaze B2 (or any S3-compatible store).** Create a bucket `openvibe-mail` and an application key
+limited to it, then set `STALWART_BLOB_STORE=s3` and the `STALWART_BLOB_*` values in
+`/etc/openvibe/stalwart.env`. The endpoint is a custom S3 region to Stalwart (for example
+`https://s3.eu-central-003.backblazeb2.com`), the region is `eu-central-003`. The service never reads blobs
+directly either way.
## 3. The Stalwart binary
## 3. The Stalwart binary
@@ -82,11 +92,15 @@ STALWART_DB_PORT=5432
@@ -82,11 +92,15 @@ STALWART_DB_PORT=5432
STALWART_DB_NAME=ov_mail
STALWART_DB_NAME=ov_mail
STALWART_DB_USER=ov_mail
STALWART_DB_USER=ov_mail
STALWART_DB_PASSWORD=REPLACE
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
+STALWART_BLOB_STORE=fs
+STALWART_BLOB_PATH=/var/lib/openvibe-stalwart/blobs
+STALWART_BLOB_DEPTH=2
+# Optional, only when STALWART_BLOB_STORE=s3:
+#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:
`/etc/openvibe/mail.env` (root:root, 0600) — names are described in `.env.example`; the values that matter for M1:
@@ -111,11 +125,14 @@ STALWART_ADMIN_PASSWORD=REPLACE_WITH_THE_RECOVERY_ADMIN_PASSWORD
@@ -111,11 +125,14 @@ STALWART_ADMIN_PASSWORD=REPLACE_WITH_THE_RECOVERY_ADMIN_PASSWORD
MAIL_EVENTS_URL=http://127.0.0.1:4300
MAIL_EVENTS_URL=http://127.0.0.1:4300
MAIL_EVENTS_CLIENT_ID=mail
MAIL_EVENTS_CLIENT_ID=mail
MAIL_EVENTS_CLIENT_SECRET=REPLACE
MAIL_EVENTS_CLIENT_SECRET=REPLACE
+OV_OAUTH_CLIENT_ID=mail
+OV_OAUTH_CLIENT_SECRET=REPLACE_WITH_THE_NETWORK_CLIENT_SECRET
+# COOKIE_SECURE defaults to true in production.
```
```
If the host uses PgBouncer, `DATABASE_URL` points at it and `DATABASE_DIRECT_URL` stays direct; migrations run on
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.
+the direct connection. The Stalwart env file needs the S3 keys only when `STALWART_BLOB_STORE=s3`; the running server reads the blob
+store from its database. The Network client secret is needed by the sign-in exchange and is never logged.
## 5. Render the configuration
## 5. Render the configuration
@@ -162,7 +179,9 @@ kill "$(cat /tmp/stalwart-recovery.pid)"
@@ -162,7 +179,9 @@ kill "$(cat /tmp/stalwart-recovery.pid)"
rm -f /etc/openvibe/stalwart-recovery.env
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.
+The first pass creates the SQL directory, the domains, the local Filesystem blob store, the spam setting and the
+three listeners. (With `STALWART_BLOB_STORE=s3` it creates the S3 store instead and the two keys are read from
+`/etc/openvibe/stalwart.env`.)
It may skip the default roles because recovery mode has not written them yet; the second pass below sets them.
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
## 7. Start the service and apply the roles
@@ -208,7 +227,35 @@ sudo systemctl enable --now openvibe-mail.service
@@ -208,7 +227,35 @@ 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
`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.
failure here is easier to read. The service adds the domains it knows on start; there is no pool mailbox.
-## 9. DNS
+## 9. The public door (nginx)
+
+The parked `openvibe.email` block in `/etc/nginx/sites-enabled/parked-domains.conf` redirects the name to Chat.
+Remove it (or comment it out) first; otherwise nginx keeps answering with the parked server. Then install the
+repository's vhost:
+
+```sh
+sudo install -m 0644 deploy/nginx/openvibe.email.conf /etc/nginx/sites-available/openvibe.email.conf
+sudo ln -sfn /etc/nginx/sites-available/openvibe.email.conf /etc/nginx/sites-enabled/openvibe.email.conf
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+The `server_name` lines are:
+
+```nginx
+server_name openvibe.email www.openvibe.email; # port 80: redirect to https
+server_name www.openvibe.email; # port 443: redirect to the apex
+server_name openvibe.email; # port 443: the API and the sign-in routes
+```
+
+The host's `/etc/nginx/conf.d/cloudflare-realip.conf` (included globally from `nginx.conf`) restores the person's
+address from `CF-Connecting-IP` for every vhost; this file does not repeat it, like the rest of the house. Only
+`/api/v1/`, `/api/health`, `/api/ready` and the Network sign-in routes (`/auth/`) are proxied to the Mail service;
+`/` is the placeholder until the page lands.
+
+Register the Network OAuth client for `mail` with redirect `https://openvibe.email/auth/callback`, and put its
+secret in `OV_OAUTH_CLIENT_SECRET` in `/etc/openvibe/mail.env`, or sign-in cannot finish.
+
+## 10. DNS
Publish the records in [`docs/DNS.md`](DNS.md), keeping the mail A/AAAA records **DNS-only** in Cloudflare. Wait for
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:
the MX, then test from outside:
@@ -218,7 +265,7 @@ dig +short MX openvibe.email
@@ -218,7 +265,7 @@ dig +short MX openvibe.email
python3 -c "import smtplib; s=smtplib.SMTP('mail.openvibe.email',25,timeout=10); print(s.ehlo()[1]); s.quit()"
python3 -c "import smtplib; s=smtplib.SMTP('mail.openvibe.email',25,timeout=10); print(s.ehlo()[1]); s.quit()"
```
```
-## 10. Verify
+## 11. Verify
```sh
```sh
curl -fsS http://127.0.0.1:5250/api/health
curl -fsS http://127.0.0.1:5250/api/health
@@ -228,9 +275,17 @@ journalctl -u openvibe-stalwart -u openvibe-mail --since '10 min ago' --no-pager
@@ -228,9 +275,17 @@ journalctl -u openvibe-stalwart -u openvibe-mail --since '10 min ago' --no-pager
```
```
Then, with a person's token, create a mask, send it one message from another account, and read it through JMAP (or
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.
+create a burner and use `GET /api/v1/burners/:id/code`). A body must never appear in either journal. Check that
+the public door answers:
+
+```sh
+curl -fsS https://openvibe.email/api/health
+curl -fsS https://openvibe.email/api/ready
+curl -fsS -o /dev/null -w '%{http_code}\n' https://openvibe.email/ # 200 placeholder
+curl -fsS -o /dev/null -w '%{http_code}\n' https://openvibe.email/api/v1/masks # 401 without a token
+```
-## 11. Firewall and things not to do
+## 12. 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.
- 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.
- Keep 8080, 143 and 5250 on loopback. The public API is whatever nginx/Network fronts; M1 has no public site.
@@ -239,12 +294,12 @@ create a burner and use `GET /api/v1/burners/:id/code`). A body must never appea
@@ -239,12 +294,12 @@ create a burner and use `GET /api/v1/burners/:id/code`). A body must never appea
- The memory cap is the slice's (`/etc/systemd/system/openvibe-stalwart.slice`). The host OOMed on 2026-11-07; do
- 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.
not raise `MemoryMax` without measuring the spam filter and a busy mailbox first.
-## 12. Rollback
+## 13. Rollback
```sh
```sh
sudo systemctl stop openvibe-mail.service openvibe-stalwart.service
sudo systemctl stop openvibe-mail.service openvibe-stalwart.service
sudo systemctl disable 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
+The data stays in PostgreSQL (`ov_mail`) and the blob store (local disk, or B2 with `STALWART_BLOB_STORE=s3`). 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.
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,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- OpenVibe.Mail — a mask can be given a life.
+--
+-- POST /api/v1/masks accepts expires_days: the mask turns itself off when the date arrives, so a per-site address
+-- made for one purchase does not outlive the purchase by accident. expires_at is when that happens; NULL means the
+-- mask lives until the person turns it off. The purge job disables the due rows (enabled = false), and the SQL
+-- directory Stalwart reads stops resolving them in the same moment.
+--
+-- The Contract resource (mail.mask@1) stays as it is: it has no expiry field, so only /me/mail's listing metadata
+-- carries expires_at.
+
+ALTER TABLE mail_masks ADD COLUMN IF NOT EXISTS expires_at timestamptz;
+
+CREATE INDEX IF NOT EXISTS mail_masks_due_idx ON mail_masks (expires_at) WHERE enabled AND expires_at IS NOT NULL;
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1063,9 +1063,9 @@
@@ -1063,9 +1063,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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==",
+ "version": "0.329.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
+ "integrity": "sha512-bSCmuAjR83aRqMwV6DcFzxsZOizJWuKwBKEnCO71r0fCuFyxQJalEgCJG6koFZ4vhVhFCLfAxKmgRLRqMZhZpQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -5,9 +5,11 @@
@@ -5,9 +5,11 @@
* database and a stand-in for the mail server.
* database and a stand-in for the mail server.
*
*
* /api/health, /api/ready the machine answers
* /api/health, /api/ready the machine answers
+ * /auth/* Network SSO with PKCE (openvibe-sdk/sso)
* /api/v1/masks, /burners, … the API (http/api.js)
* /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.
+ * There are no pages: M1 has no site, only an API for the apps and the agents of the house. nginx answers `/` with
+ * a placeholder until the page lands.
*
*
* Nothing here logs a request body. A request body of this service may carry a person's label for a mask; a
* 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
* message body never reaches this process at all, and the one place a body is read (the code extractor) throws it
@@ -22,10 +24,12 @@ const configLib = require('./config');
@@ -22,10 +24,12 @@ const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
+const { createCors } = require('./http/cors');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createStalwartClient } = require('./stalwart/client');
const { createStalwartClient } = require('./stalwart/client');
const { createEvents } = require('./events');
const { createEvents } = require('./events');
+const { createSsoClient } = require('openvibe-sdk/sso');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -42,6 +46,22 @@ async function createApp(opts = {}) {
@@ -42,6 +46,22 @@ async function createApp(opts = {}) {
const stalwart = opts.stalwart || createStalwartClient({ 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 events = opts.events || createEvents({ db: store.db, config, now: opts.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
+ const sso = opts.sso || createSsoClient({
+ site: 'mail',
+ siteName: 'OpenVibe.Mail',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ networkUrl: config.network.url,
+ networkInternalUrl: config.network.internalUrl,
+ issuer: config.network.issuer,
+ audience: [config.network.sessionAudience],
+ secureCookies: config.cookies.secure,
+ fetch: fetchImpl,
+ log,
+ });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -50,12 +70,36 @@ async function createApp(opts = {}) {
@@ -50,12 +70,36 @@ async function createApp(opts = {}) {
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
app.use(express.json({ limit: '16kb' }));
app.use(express.json({ limit: '16kb' }));
+ // Nothing here is cacheable: a person's addresses and sign-in are private, and a machine answer is live.
+ app.use(['/api', '/auth'], (_req, res, next) => {
+ res.set('Cache-Control', 'no-store');
+ next();
+ });
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-mail', name: 'OpenVibe.Mail', version: VERSION }));
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 });
const readiness = createServiceReadiness({ db: store.db, config, stalwart, version: VERSION });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // Sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback, /auth/me, /auth/logout. The
+ // session lives in the SDK's host-only ov_token cookie on openvibe.email. A request that already carries a
+ // Bearer token is judged by that token alone, so the cookie is not even read for it.
+ app.use((req, res, next) => (req.headers.authorization ? next() : sso.optionalAuth()(req, res, next)));
+ app.use('/auth', rateLimit({
+ windowMs: 15 * 60_000,
+ limit: 60,
+ standardHeaders: true,
+ legacyHeaders: false,
+ handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', {
+ detail: 'Too many sign-in requests from this address; retry shortly.',
+ ctx: req.ov,
+ }),
+ }));
+ app.use('/auth', sso.router(express));
+
+ // CORS: a Bearer token may be sent from any first-party origin in the manifests; a cookie may not cross sites.
+ app.use('/api/v1', createCors({ baseUrl: config.baseUrl }));
+
app.use('/api/v1', rateLimit({
app.use('/api/v1', rateLimit({
windowMs: 60_000,
windowMs: 60_000,
limit: config.limits.apiPerMinute,
limit: config.limits.apiPerMinute,
@@ -89,7 +133,7 @@ async function createApp(opts = {}) {
@@ -89,7 +133,7 @@ async function createApp(opts = {}) {
return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error.', 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 } };
+ return { app, ctx: { config, store, keys, stalwart, events, principal, sso, log } };
}
}
module.exports = { createApp, VERSION };
module.exports = { createApp, VERSION };
@@ -44,6 +44,7 @@ function load(overrides = null) {
@@ -44,6 +44,7 @@ function load(overrides = null) {
const baseDomain = domain('MAIL_BASE_DOMAIN', 'openvibe.email');
const baseDomain = domain('MAIL_BASE_DOMAIN', 'openvibe.email');
const maskDomain = domain('MAIL_MASK_DOMAIN', `mask.${baseDomain}`);
const maskDomain = domain('MAIL_MASK_DOMAIN', `mask.${baseDomain}`);
const burnerDomains = list('BURNER_DOMAINS');
const burnerDomains = list('BURNER_DOMAINS');
+ const baseUrl = String(env.BASE_URL || `https://${baseDomain}`).replace(/\/+$/, '');
if (isProduction && !env.DATABASE_URL) {
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)');
throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data/add-service.sh today)');
@@ -64,7 +65,7 @@ function load(overrides = null) {
@@ -64,7 +65,7 @@ function load(overrides = null) {
service: 'mail',
service: 'mail',
port: int('PORT', 5250),
port: int('PORT', 5250),
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
- baseUrl: env.BASE_URL || `https://${baseDomain}`,
+ baseUrl,
trustProxy: int('TRUST_PROXY', 0),
trustProxy: int('TRUST_PROXY', 0),
db: {
db: {
@@ -86,6 +87,16 @@ function load(overrides = null) {
@@ -86,6 +87,16 @@ function load(overrides = null) {
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
},
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'mail',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: env.OV_OAUTH_SCOPE || 'profile',
+ },
+ cookies: {
+ secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction,
+ },
+
addresses: {
addresses: {
base: baseDomain,
base: baseDomain,
mask: maskDomain,
mask: maskDomain,
@@ -37,7 +37,7 @@ function shape(row) {
@@ -37,7 +37,7 @@ function shape(row) {
return {
return {
id: row.id,
id: row.id,
address: row.address,
address: row.address,
- owner: row.owner_subject || null,
+ owner: row.owner_subject || row.owner || null,
expires_at: row.expires_at instanceof Date ? row.expires_at.toISOString() : row.expires_at,
expires_at: row.expires_at instanceof Date ? row.expires_at.toISOString() : row.expires_at,
first_message_only: Boolean(row.first_message_only),
first_message_only: Boolean(row.first_message_only),
created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
@@ -138,11 +138,30 @@ async function createBurner({
@@ -138,11 +138,30 @@ async function createBurner({
/** The person's burners, newest first. */
/** The person's burners, newest first. */
async function listBurners({ db, owner }) {
async function listBurners({ db, owner }) {
+ const rows = await listBurnerRows({ db, owner });
+ return rows.map(shape);
+}
+
+/** The person's burners with the fields /me/mail needs beyond the contract (first message and expiry). */
+async function listBurnerRows({ db, owner }) {
const rows = await db.many(sql`
const rows = await db.many(sql`
- SELECT id, owner_subject, address, expires_at, first_message_only, created_at
+ SELECT id, owner_subject, address, expires_at, first_message_only, first_message_at, created_at
FROM mail_burners WHERE owner_subject = ${owner}
FROM mail_burners WHERE owner_subject = ${owner}
ORDER BY created_at DESC`);
ORDER BY created_at DESC`);
- return rows.map(shape);
+ return rows.map((row) => ({
+ ...shape(row),
+ first_message_at: row.first_message_at instanceof Date
+ ? row.first_message_at.toISOString()
+ : (row.first_message_at || null),
+ }));
+}
+
+/** live (accepting mail), consumed (first_message_only and it has taken one) or expired. */
+function stateOf(row, now = () => Date.now()) {
+ if (!row) return null;
+ if (new Date(row.expires_at).getTime() <= now()) return 'expired';
+ if (row.first_message_only && row.first_message_at) return 'consumed';
+ return 'live';
}
}
/** One burner, whoever's it is. The caller decides what it may do with it. */
/** One burner, whoever's it is. The caller decides what it may do with it. */
@@ -254,6 +273,8 @@ module.exports = {
@@ -254,6 +273,8 @@ module.exports = {
ANON_EXPIRY,
ANON_EXPIRY,
createBurner,
createBurner,
listBurners,
listBurners,
+ listBurnerRows,
+ stateOf,
findBurner,
findBurner,
authorizeBurner,
authorizeBurner,
mailboxOf,
mailboxOf,
@@ -6,6 +6,10 @@
@@ -6,6 +6,10 @@
* A mask never forwards anywhere. It delivers into the owner's mailbox in OpenVibe and stops there, and turning it
* 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
* off makes it bounce: the address leaves the directory view, and the mail server is told to forget what it cached
* about it.
* about it.
+ *
+ * A mask may also be given a life: `expires_days` stores when it is due to turn itself off. The purge job disables
+ * it then, so the expiry is a fact of the database, not a date the page has to remember. `expires_at` is listing
+ * metadata; the mail.mask@1 resource stays exactly the Contract (it has no expiry field).
*/
*/
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
@@ -14,11 +18,14 @@ const { ensureOwnerMailbox } = require('./mailboxes');
@@ -14,11 +18,14 @@ const { ensureOwnerMailbox } = require('./mailboxes');
const { countMasks } = require('./limits');
const { countMasks } = require('./limits');
const WORDS_FOR_LABEL = 1;
const WORDS_FOR_LABEL = 1;
+const MAX_EXPIRY_DAYS = 3650;
+const DAY_MS = 24 * 60 * 60 * 1000;
function shape(row) {
function shape(row) {
if (!row) return null;
if (!row) return null;
return {
return {
id: row.id,
id: row.id,
+ owner: row.owner_subject || row.owner || null,
address: row.address,
address: row.address,
label: row.label || null,
label: row.label || null,
purpose: row.purpose || null,
purpose: row.purpose || null,
@@ -27,6 +34,34 @@ function shape(row) {
@@ -27,6 +34,34 @@ function shape(row) {
};
};
}
}
+/** A row of the person's list, with the fields /me/mail needs beyond the contract (expiry). */
+function listShape(row) {
+ const mask = shape(row);
+ if (!mask) return null;
+ return {
+ ...mask,
+ expires_at: row.expires_at instanceof Date ? row.expires_at.toISOString() : (row.expires_at || null),
+ };
+}
+
+function stateOf(row, now = () => Date.now()) {
+ if (row.expires_at && new Date(row.expires_at).getTime() <= now()) return 'expired';
+ if (!row.enabled) return 'disabled';
+ return 'active';
+}
+
+function expiryDays(value) {
+ if (value === undefined || value === null || value === '') return null;
+ const n = Number(value);
+ if (!Number.isInteger(n) || n < 1 || n > MAX_EXPIRY_DAYS) {
+ const err = new Error(`expires_days must be a whole number from 1 to ${MAX_EXPIRY_DAYS}`);
+ err.code = 'mail.bad_expiry';
+ err.status = 400;
+ throw err;
+ }
+ return n;
+}
+
/**
/**
* Make a mask for a person.
* Make a mask for a person.
*
*
@@ -34,9 +69,13 @@ function shape(row) {
@@ -34,9 +69,13 @@ function shape(row) {
* characters are what keeps the address unguessable. A collision is retried with new characters — the words stay
* characters are what keeps the address unguessable. A collision is retried with new characters — the words stay
* the person's.
* the person's.
*/
*/
-async function createMask({ db, config, events, owner, label = null, purpose = null, now = () => Date.now() }) {
+async function createMask({
+ db, config, events, owner, label = null, purpose = null, expiresDays = null, now = () => Date.now(),
+}) {
const words = maskWords(label || purpose || '');
const words = maskWords(label || purpose || '');
const limit = config.limits.maxMasks;
const limit = config.limits.maxMasks;
+ const days = expiryDays(expiresDays);
+ const expiresAt = days ? new Date(now() + days * DAY_MS) : null;
return db.tx(async (t) => {
return db.tx(async (t) => {
const mailbox = await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });
const mailbox = await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });
@@ -50,14 +89,15 @@ async function createMask({ db, config, events, owner, label = null, purpose = n
@@ -50,14 +89,15 @@ async function createMask({ db, config, events, owner, label = null, purpose = n
for (let attempt = 0; attempt < 6; attempt++) {
for (let attempt = 0; attempt < 6; attempt++) {
const address = maskAddress(words, config.addresses.mask);
const address = maskAddress(words, config.addresses.mask);
const row = await t.maybe(sql`
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)
+ INSERT INTO mail_masks (id, owner_subject, address, label, purpose, enabled, expires_at)
+ VALUES (${`msk_${ids.ulid(now())}`}, ${owner}, ${address}, ${label || null}, ${purpose || null}, true,
+ ${expiresAt ? expiresAt.toISOString() : null})
ON CONFLICT (address) DO NOTHING
ON CONFLICT (address) DO NOTHING
- RETURNING id, address, label, purpose, enabled, created_at`);
+ RETURNING id, owner_subject, address, label, purpose, enabled, expires_at, created_at`);
if (!row) continue;
if (!row) continue;
const mask = shape(row);
const mask = shape(row);
if (events) await events.outbox.emit(events.maskCreated({ owner, mask }));
if (events) await events.outbox.emit(events.maskCreated({ owner, mask }));
- return mask;
+ return shape(row);
}
}
const err = new Error('the address is taken; try again');
const err = new Error('the address is taken; try again');
err.code = 'mail.address_taken';
err.code = 'mail.address_taken';
@@ -66,19 +106,25 @@ async function createMask({ db, config, events, owner, label = null, purpose = n
@@ -66,19 +106,25 @@ async function createMask({ db, config, events, owner, label = null, purpose = n
});
});
}
}
-/** The person's masks, newest first. */
+/** The person's masks, newest first, in the mail.mask@1 shape. */
async function listMasks({ db, owner }) {
async function listMasks({ db, owner }) {
+ const rows = await listMaskRows({ db, owner });
+ return rows.map(shape);
+}
+
+/** The person's masks with the listing metadata (/me/mail): the contract fields and the expiry. */
+async function listMaskRows({ db, owner }) {
const rows = await db.many(sql`
const rows = await db.many(sql`
- SELECT id, address, label, purpose, enabled, created_at
+ SELECT id, owner_subject, address, label, purpose, enabled, expires_at, created_at
FROM mail_masks WHERE owner_subject = ${owner}
FROM mail_masks WHERE owner_subject = ${owner}
ORDER BY created_at DESC`);
ORDER BY created_at DESC`);
- return rows.map(shape);
+ return rows.map(listShape);
}
}
/** One mask of this person, or null. Somebody else's mask is not found, not forbidden. */
/** One mask of this person, or null. Somebody else's mask is not found, not forbidden. */
async function findMask({ db, owner, id }) {
async function findMask({ db, owner, id }) {
const row = await db.maybe(sql`
const row = await db.maybe(sql`
- SELECT id, address, label, purpose, enabled, created_at
+ SELECT id, owner_subject, address, label, purpose, enabled, expires_at, created_at
FROM mail_masks WHERE id = ${id} AND owner_subject = ${owner}`);
FROM mail_masks WHERE id = ${id} AND owner_subject = ${owner}`);
return shape(row);
return shape(row);
}
}
@@ -92,8 +138,8 @@ async function disableMask({ db, stalwart, owner, id, now = () => Date.now() })
@@ -92,8 +138,8 @@ async function disableMask({ db, stalwart, owner, id, now = () => Date.now() })
const row = await t.maybe(sql`
const row = await t.maybe(sql`
UPDATE mail_masks SET enabled = false
UPDATE mail_masks SET enabled = false
WHERE id = ${id} AND owner_subject = ${owner} AND enabled
WHERE id = ${id} AND owner_subject = ${owner} AND enabled
- RETURNING id, address, label, purpose, enabled, created_at`);
- return shape(row);
+ RETURNING id, owner_subject, address, label, purpose, enabled, expires_at, created_at`);
+ return row || null;
});
});
if (!mask) return null;
if (!mask) return null;
if (stalwart) {
if (stalwart) {
@@ -104,7 +150,43 @@ async function disableMask({ db, stalwart, owner, id, now = () => Date.now() })
@@ -104,7 +150,43 @@ async function disableMask({ db, stalwart, owner, id, now = () => Date.now() })
(console.warn || console.log)(`[mail] the mail server did not take the cache notice: ${err && err.message}`);
(console.warn || console.log)(`[mail] the mail server did not take the cache notice: ${err && err.message}`);
}
}
}
}
- return mask;
+ return shape(mask);
+}
+
+/**
+ * The masks whose life has run out: disable them and tell the mail server to forget them.
+ *
+ * Due-based like the burner purge: only rows whose expires_at has passed are touched, so a service that was down
+ * for a day catches up on its first run. A failure leaves the row enabled; the next run tries again.
+ */
+async function disableDueMasks({ db, stalwart, now = () => Date.now() }) {
+ const rows = await db.tx(async (t) => t.many(sql`
+ UPDATE mail_masks SET enabled = false
+ WHERE enabled
+ AND expires_at IS NOT NULL
+ AND expires_at <= ${new Date(now()).toISOString()}
+ RETURNING id, address`));
+ if (rows.length && stalwart) {
+ try {
+ await stalwart.invalidateCaches();
+ } catch (err) {
+ (console.warn || console.log)(`[mail] the mail server did not take the mask-expiry cache notice: ${err && err.message}`);
+ }
+ }
+ return rows.length;
}
}
-module.exports = { createMask, listMasks, findMask, disableMask, shape, WORDS_FOR_LABEL };
+module.exports = {
+ createMask,
+ listMasks,
+ listMaskRows,
+ findMask,
+ disableMask,
+ disableDueMasks,
+ shape,
+ listShape,
+ stateOf,
+ expiryDays,
+ WORDS_FOR_LABEL,
+ MAX_EXPIRY_DAYS,
+};
@@ -63,4 +63,22 @@ async function observedIds({ db, address }) {
@@ -63,4 +63,22 @@ async function observedIds({ db, address }) {
return rows.map((r) => r.message_id);
return rows.map((r) => r.message_id);
}
}
-module.exports = { summary, observe, listForAddress, observedIds };
+/**
+ * How many messages the service has already seen for each of these addresses.
+ *
+ * This is the count of the observation table, not a walk of every mailbox: opening an address's messages or its
+ * code writes the row. It is deliberately cheap and carries no subject, sender or body — /me/mail draws a badge,
+ * it does not read mail.
+ */
+async function countsByAddress({ db, addresses = [] }) {
+ const list = [...new Set((addresses || []).filter(Boolean))];
+ if (!list.length) return new Map();
+ const rows = await db.many(sql`
+ SELECT address, count(*)::int AS messages
+ FROM mail_observed_messages
+ WHERE address = ANY(${list})
+ GROUP BY address`);
+ return new Map(rows.map((r) => [r.address, Number(r.messages) || 0]));
+}
+
+module.exports = { summary, observe, listForAddress, observedIds, countsByAddress };
@@ -3,10 +3,13 @@
@@ -3,10 +3,13 @@
/**
/**
* The API of OpenVibe.Mail, v1.
* The API of OpenVibe.Mail, v1.
*
*
+ * GET /api/v1/me/mail the person's masks, burners and agent inboxes, with state and counts
+ *
* POST /api/v1/masks a mask for the person
* POST /api/v1/masks a mask for the person
* GET /api/v1/masks the person's masks
* GET /api/v1/masks the person's masks
* GET /api/v1/masks/:id one mask of the person
* 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/masks/:id/disable turn a mask off (it bounces from then on)
+ * DELETE /api/v1/masks/:id the same, for a DELETE-shaped client
*
*
* POST /api/v1/burners a burner (with an account, or the ten-minute one without)
* 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 the person's burners
@@ -30,6 +33,7 @@ const { http } = require('openvibe-contracts');
@@ -30,6 +33,7 @@ const { http } = require('openvibe-contracts');
const masks = require('../domain/masks');
const masks = require('../domain/masks');
const burners = require('../domain/burners');
const burners = require('../domain/burners');
const agentInboxes = require('../domain/agent-inboxes');
const agentInboxes = require('../domain/agent-inboxes');
+const messages = require('../domain/messages');
function createApi({ db, config, stalwart, events, principal, log = console }) {
function createApi({ db, config, stalwart, events, principal, log = console }) {
const router = express.Router();
const router = express.Router();
@@ -54,6 +58,78 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
@@ -54,6 +58,78 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
return http.sendProblem(res, status, code, { detail, ctx: req.ov });
return http.sendProblem(res, status, code, { detail, ctx: req.ov });
}
}
+ // ---- the person's front door ---------------------------------------------------------------
+
+ /**
+ * Everything the page draws, in one read: the three kinds of address, each with its state and how many
+ * messages the service has seen for it, plus the totals.
+ *
+ * Every `mask`, `burner` and `agent_inbox` object inside an item is exactly the Contracts resource
+ * (mail.mask@1, mail.burner@1, mail.agent-inbox@1); `state`, `expires_at` (a mask) and `counts` sit beside it.
+ * No message body, subject, sender or code is in this answer.
+ */
+ router.get('/api/v1/me/mail', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
+ const owner = ownerOf(req);
+ const [maskRows, burnerRows, inboxes] = await Promise.all([
+ masks.listMaskRows({ db, owner }),
+ burners.listBurnerRows({ db, owner }),
+ agentInboxes.listAgentInboxes({ db, owner }),
+ ]);
+ const addresses = [
+ ...maskRows.map((m) => m.address),
+ ...burnerRows.map((b) => b.address),
+ ...inboxes.map((i) => i.address),
+ ];
+ const seen = await messages.countsByAddress({ db, addresses });
+ const countOf = (address) => seen.get(address) || 0;
+ const now = () => Date.now();
+
+ const maskItems = maskRows.map((row) => ({
+ mask: masks.shape(row),
+ address: row.address,
+ purpose: row.purpose || null,
+ state: masks.stateOf(row, now),
+ expires_at: row.expires_at || null,
+ counts: { messages: countOf(row.address) },
+ }));
+ const burnerItems = burnerRows.map((row) => ({
+ burner: burners.shape(row),
+ address: row.address,
+ purpose: null,
+ state: burners.stateOf(row, now),
+ counts: { messages: countOf(row.address) },
+ }));
+ const inboxItems = inboxes.map((inbox) => ({
+ agent_inbox: inbox,
+ address: inbox.address,
+ purpose: null,
+ state: 'active',
+ counts: { messages: countOf(inbox.address) },
+ }));
+
+ return res.json({
+ masks: maskItems,
+ burners: burnerItems,
+ agent_inboxes: inboxItems,
+ counts: {
+ masks: {
+ total: maskItems.length,
+ active: maskItems.filter((m) => m.state === 'active').length,
+ messages: maskItems.reduce((n, m) => n + m.counts.messages, 0),
+ },
+ burners: {
+ total: burnerItems.length,
+ live: burnerItems.filter((b) => b.state === 'live').length,
+ messages: burnerItems.reduce((n, b) => n + b.counts.messages, 0),
+ },
+ agent_inboxes: {
+ total: inboxItems.length,
+ messages: inboxItems.reduce((n, i) => n + i.counts.messages, 0),
+ },
+ },
+ });
+ }));
+
// ---- masks -------------------------------------------------------------------------------
// ---- masks -------------------------------------------------------------------------------
router.post('/api/v1/masks', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
router.post('/api/v1/masks', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
@@ -67,6 +143,7 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
@@ -67,6 +143,7 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
owner,
owner,
label: typeof body.label === 'string' ? body.label.slice(0, 120) : null,
label: typeof body.label === 'string' ? body.label.slice(0, 120) : null,
purpose: typeof body.purpose === 'string' ? body.purpose.slice(0, 120) : null,
purpose: typeof body.purpose === 'string' ? body.purpose.slice(0, 120) : null,
+ expiresDays: body.expires_days,
});
});
return res.status(201).json({ mask });
return res.status(201).json({ mask });
} catch (err) {
} catch (err) {
@@ -86,6 +163,12 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
@@ -86,6 +163,12 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
return res.json({ mask });
return res.json({ mask });
}));
}));
+ router.post('/api/v1/masks/:id/disable', 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 });
+ }));
+
router.delete('/api/v1/masks/:id', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
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 });
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.');
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * The API's CORS rule, the house one: the browser may read a response from any first-party origin the service
+ * manifests declare (openvibe.network, openvibe.blog, …), and from this site itself. Nothing else — no wildcard, no
+ * tenant subdomain, no `http://` origin. Every service repeats this small helper rather than adding a dependency;
+ * the allow-list is the part that matters.
+ *
+ * The API authenticates a cross-origin caller with a Bearer token, never with a cookie. A browser does not send
+ * `Access-Control-Allow-Credentials`, so a cross-origin `fetch` cannot carry the person's openvibe.email session
+ * cookie; a cookie-authenticated write is checked for same-origin in http/principal.js.
+ */
+const contracts = require('openvibe-contracts');
+
+const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+let cached = null;
+
+/** The https origins of every first-party domain the installed Contracts manifest names. */
+function manifestOrigins() {
+ if (cached) return cached;
+ const out = new Set();
+ for (const manifest of (contracts.services && contracts.services.manifests) || []) {
+ for (const domainName of manifest.domains || []) {
+ const host = String(domainName || '').trim().toLowerCase();
+ if (HOST_RE.test(host)) out.add(`https://${host}`);
+ }
+ }
+ cached = out;
+ return out;
+}
+
+/** An origin-only form of a configured URL, or null. */
+function originOf(url) {
+ try {
+ const u = new URL(String(url || ''));
+ return u.origin === 'null' ? null : u.origin;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * opts.extraOrigins additional exact origins (tests and an operator's override; still no wildcard)
+ * opts.baseUrl this site's origin, always allowed (the same-origin API calls need no CORS, but a page that
+ * names its own origin explicitly is still first-party)
+ */
+function createCors({ extraOrigins = [], baseUrl = null } = {}) {
+ const allowed = new Set(manifestOrigins());
+ const own = originOf(baseUrl);
+ if (own) allowed.add(own);
+ for (const value of extraOrigins || []) {
+ const origin = originOf(value);
+ if (origin) allowed.add(origin);
+ }
+
+ return function cors(req, res, next) {
+ const origin = req.get('origin');
+ if (origin && allowed.has(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.vary('Origin');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
+ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ res.set('Access-Control-Expose-Headers', 'X-OpenVibe-Request-Id, Retry-After');
+ res.set('Access-Control-Max-Age', '600');
+ }
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ return next();
+ };
+}
+
+module.exports = { createCors, manifestOrigins, originOf };
@@ -3,8 +3,11 @@
@@ -3,8 +3,11 @@
/**
/**
* Who is calling /api/v1 — req.principal:
* Who is calling /api/v1 — req.principal:
*
*
- * { kind: 'user', subject: 'usr_…', role, username }
- * a person, with their Network session token as a Bearer.
+ * { kind: 'user', subject: 'usr_…', role, username, viaSession }
+ * a person, with their Network session token as a Bearer, or the openvibe.email session cookie set by
+ * openvibe-sdk/sso after sign-in. A cookie-authenticated write must come from openvibe.email itself
+ * (Sec-Fetch-Site same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor
+ * create a mask, turn one off or spend their burner allowance.
* { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', subject: 'usr_…' | null, capabilities, claims }
* { 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
* 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,
* for (`on_behalf_of`), the app acts for that person and is held to that person's limits. When it does not,
@@ -13,14 +16,15 @@
@@ -13,14 +16,15 @@
* nobody. The only thing an anonymous caller may do is ask for a burner, and only within the limits that are
* 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.
* 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.
+ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
+ * session or 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 (openvibe-sdk's verifyUserToken enforces it).
*/
*/
const { serviceAuth, http, ids } = require('openvibe-contracts');
const { serviceAuth, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const USER_SUB = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUB = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// The capabilities Network may grant to an app that acts for a person. A person with their own token needs none.
// The capabilities Network may grant to an app that acts for a person. A person with their own token needs none.
const CAPABILITIES = {
const CAPABILITIES = {
@@ -28,6 +32,15 @@ const CAPABILITIES = {
@@ -28,6 +32,15 @@ const CAPABILITIES = {
read: 'mail.messages.read',
read: 'mail.messages.read',
};
};
+/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
+function sameOrigin(req, baseUrl) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin';
+ const origin = req.get('origin');
+ if (!origin) return false;
+ return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
+}
+
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
if (parts.length !== 3) return null;
@@ -57,50 +70,81 @@ function capabilitiesOf(claims) {
@@ -57,50 +70,81 @@ function capabilitiesOf(claims) {
function createPrincipal({ config, keys }) {
function createPrincipal({ config, keys }) {
async function resolve(req) {
async function resolve(req) {
const header = String(req.headers.authorization || '');
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;
+ if (header.startsWith('Bearer ')) {
+ 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 {
return {
principal: {
principal: {
- kind: 'app',
- requester: requesterOfSub(claims.sub),
- subject: behalf,
- capabilities: capabilitiesOf(claims),
- claims,
+ kind: 'user',
+ subject: v.claims.subject_id,
+ requester: `user:${v.claims.subject_id}`,
+ capabilities: CAPABILITIES,
+ role: roleClaim(v.claims),
+ username: usernameClaim(v.claims),
+ token,
+ viaSession: false,
},
},
};
};
}
}
- 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'] };
+ // No Authorization header: openvibe-sdk/sso's optionalAuth has verified the session cookie, if one was
+ // presented. A cookie that does not verify is anonymous; a Bearer header, present but bad, is never
+ // downgraded to the cookie (the branch above returns the error).
+ const viewer = req.user;
+ if (viewer && ids.isSubjectId('user', viewer.subject_id)) {
+ return {
+ principal: {
+ kind: 'user',
+ subject: viewer.subject_id,
+ requester: `user:${viewer.subject_id}`,
+ capabilities: CAPABILITIES,
+ role: roleClaim(viewer),
+ username: usernameClaim(viewer),
+ token: req.token,
+ viaSession: true,
+ },
+ };
}
}
- 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,
- },
- };
+ return { principal: { kind: 'anonymous' } };
}
}
async function middleware(req, res, next) {
async function middleware(req, res, next) {
const r = await resolve(req);
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 });
if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ // A write authenticated by the session cookie must come from this site. (A Bearer token has no such rule:
+ // the token is the caller's, and a browser cannot read a cross-origin response without CORS approval.)
+ if (r.principal.kind === 'user' && r.principal.viaSession && !SAFE_METHODS.has(req.method)
+ && !sameOrigin(req, config.baseUrl)) {
+ return http.sendProblem(res, 403, 'mail.cross_site', {
+ detail: 'A signed-in request that changes something must come from openvibe.email itself.',
+ ctx: req.ov,
+ });
+ }
req.principal = r.principal;
req.principal = r.principal;
return next();
return next();
}
}
@@ -151,4 +195,4 @@ function createPrincipal({ config, keys }) {
@@ -151,4 +195,4 @@ function createPrincipal({ config, keys }) {
return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };
return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };
}
}
-module.exports = { createPrincipal, CAPABILITIES, decodePayload };
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const { staleCounters } = require('../domain/limits');
const { staleCounters } = require('../domain/limits');
const { observe } = require('../domain/messages');
const { observe } = require('../domain/messages');
+const { disableDueMasks } = require('../domain/masks');
const LEASE_KEY = 'purge_lease';
const LEASE_KEY = 'purge_lease';
const LEASE_MS = 30_000;
const LEASE_MS = 30_000;
@@ -84,7 +85,7 @@ async function purgeBurner({ db, stalwart, burner }) {
@@ -84,7 +85,7 @@ async function purgeBurner({ db, stalwart, burner }) {
* Returns what it did, so the run can be logged without saying anything about the mail itself.
* 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 } = {}) {
async function runDue({ db, stalwart, events, now = () => Date.now(), log = console } = {}) {
- const report = { expired: 0, messagesDeleted: 0, consumed: 0, counters: 0, skipped: false };
+ const report = { expired: 0, messagesDeleted: 0, masksDisabled: 0, consumed: 0, counters: 0, skipped: false };
const held = await db.tx(async (t) => takeLease(t, { now: now() }));
const held = await db.tx(async (t) => takeLease(t, { now: now() }));
if (!held) {
if (!held) {
report.skipped = true;
report.skipped = true;
@@ -111,6 +112,14 @@ async function runDue({ db, stalwart, events, now = () => Date.now(), log = cons
@@ -111,6 +112,14 @@ async function runDue({ db, stalwart, events, now = () => Date.now(), log = cons
}
}
}
}
+ // A mask with a date stops resolving on that date. It is the same due-based rule: only the rows whose
+ // expires_at has passed are touched, and the mail server is told once (disableDueMasks does that).
+ try {
+ report.masksDisabled = await disableDueMasks({ db, stalwart, now });
+ } catch (err) {
+ (log.warn || log.log).call(log, `[mail] the due masks were not disabled: ${err && err.message}`);
+ }
+
const waiting = await db.tx(async (t) => waitingBurners(t));
const waiting = await db.tx(async (t) => waitingBurners(t));
for (const burner of waiting) {
for (const burner of waiting) {
if (!stalwart) continue;
if (!stalwart) continue;
@@ -146,8 +155,8 @@ function startPurgeJob({ db, stalwart, events, intervalMs = 60_000, now = () =>
@@ -146,8 +155,8 @@ function startPurgeJob({ db, stalwart, events, intervalMs = 60_000, now = () =>
if (stopped) return;
if (stopped) return;
try {
try {
const report = await runDue({ db, stalwart, events, now, log });
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`);
+ if (!report.skipped && (report.expired || report.masksDisabled || report.consumed)) {
+ (log.info || log.log).call(log, `[mail] purge: ${report.expired} burners expired, ${report.masksDisabled} masks disabled, ${report.messagesDeleted} messages deleted`);
}
}
} catch (err) {
} catch (err) {
(log.warn || log.log).call(log, `[mail] the purge job failed: ${err && err.message}`);
(log.warn || log.log).call(log, `[mail] the purge job failed: ${err && err.message}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+
+/**
+ * The Stalwart plan: fs is M1's default blob store, s3 stays available, and apply.js turns either plan into the
+ * JMAP object Stalwart expects. These tests do not need the binary or a database.
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+const { parse } = require('../deploy/stalwart/toml');
+const { render } = require('../deploy/stalwart/render');
+const { blobStoreFor } = require('../deploy/stalwart/apply');
+
+const CONFIG = parse(fs.readFileSync(path.join(__dirname, '..', 'deploy', 'stalwart', 'config.toml'), 'utf8'));
+const BASE_ENV = {
+ STALWART_DB_HOST: '127.0.0.1',
+ STALWART_DB_NAME: 'ov_mail',
+ STALWART_DB_USER: 'ov_mail',
+ MAIL_BASE_DOMAIN: 'openvibe.email',
+ MAIL_MASK_DOMAIN: 'mask.openvibe.email',
+ BURNER_DOMAINS: 'burn1.openvibe.email',
+};
+
+test('the shipped configuration defaults to local filesystem blobs under /var/lib/openvibe-stalwart/blobs', () => {
+ assert.strictEqual(CONFIG.blob_store.kind, 'fs');
+ assert.strictEqual(CONFIG.blob_store.path, '/var/lib/openvibe-stalwart/blobs');
+
+ const { plan } = render(CONFIG, BASE_ENV);
+ assert.deepStrictEqual(plan.blob_store, {
+ kind: 'fs',
+ path: '/var/lib/openvibe-stalwart/blobs',
+ depth: 2,
+ });
+});
+
+test('STALWART_BLOB_STORE=fs overrides a file that says s3, and STALWART_BLOB_PATH/DEPTH are read', () => {
+ const config = { ...CONFIG, blob_store: { ...CONFIG.blob_store, kind: 's3' } };
+ const { plan } = render(config, {
+ ...BASE_ENV,
+ STALWART_BLOB_STORE: 'fs',
+ STALWART_BLOB_PATH: '/srv/mail/blobs',
+ STALWART_BLOB_DEPTH: '3',
+ });
+ assert.deepStrictEqual(plan.blob_store, { kind: 'fs', path: '/srv/mail/blobs', depth: 3 });
+});
+
+test('STALWART_BLOB_STORE=s3 keeps the existing B2/MinIO path', () => {
+ const { plan } = render(CONFIG, {
+ ...BASE_ENV,
+ STALWART_BLOB_STORE: 's3',
+ STALWART_BLOB_ENDPOINT: 'http://127.0.0.1:9000',
+ STALWART_BLOB_BUCKET: 'ov-mail',
+ STALWART_BLOB_REGION: 'us-east-1',
+ });
+ assert.strictEqual(plan.blob_store.kind, 's3');
+ assert.strictEqual(plan.blob_store.endpoint, 'http://127.0.0.1:9000');
+ assert.strictEqual(plan.blob_store.bucket, 'ov-mail');
+ assert.strictEqual(plan.blob_store.accessKeyIdEnv, 'STALWART_BLOB_ACCESS_KEY_ID');
+});
+
+test('an unknown blob store kind is refused at render time', () => {
+ assert.throws(() => render(CONFIG, { ...BASE_ENV, STALWART_BLOB_STORE: 'ftp' }), /must be "fs" or "s3"/);
+ assert.throws(() => render(CONFIG, { ...BASE_ENV, STALWART_BLOB_DEPTH: '9' }), /0 to 5/);
+});
+
+test('apply.js builds the FileSystem object without asking for S3 keys', () => {
+ assert.deepStrictEqual(blobStoreFor({ kind: 'fs', path: '/var/lib/openvibe-stalwart/blobs', depth: 2 }, {}), {
+ '@type': 'FileSystem',
+ path: '/var/lib/openvibe-stalwart/blobs',
+ depth: 2,
+ });
+});
+
+test('apply.js builds the S3 object from the environment, and refuses a missing key', () => {
+ const blob = {
+ kind: 's3',
+ endpoint: 'http://127.0.0.1:9000',
+ bucket: 'ov-mail',
+ region: 'us-east-1',
+ accessKeyIdEnv: 'STALWART_BLOB_ACCESS_KEY_ID',
+ secretKeyEnv: 'STALWART_BLOB_SECRET_KEY',
+ timeoutMs: 30000,
+ maxRetries: 3,
+ verifyAfterWrite: true,
+ };
+ assert.throws(() => blobStoreFor(blob, {}), /STALWART_BLOB_ACCESS_KEY_ID/);
+ const object = blobStoreFor(blob, { STALWART_BLOB_ACCESS_KEY_ID: 'key', STALWART_BLOB_SECRET_KEY: 'secret' });
+ assert.strictEqual(object['@type'], 'S3');
+ assert.strictEqual(object.accessKeyId.value, 'key');
+ assert.strictEqual(object.secretKey.secret, 'secret');
+ assert.strictEqual(object.bucket, 'ov-mail');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+'use strict';
+
+/**
+ * The front door: CORS, the same-site session cookie, and the one listing the page reads.
+ *
+ * These are the rules that decide who may reach the API from a browser, and they are easy to get subtly wrong:
+ * a Bearer token is fine from any first-party origin, a cookie is not; a cookie write must come from
+ * openvibe.email itself; and /me/mail carries no message content.
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const contracts = require('openvibe-contracts');
+const { boot } = require('./helpers/boot');
+
+const ALLOWED = 'https://openvibe.network';
+const OTHER = 'https://example.invalid';
+
+test('CORS lets a first-party origin read the API, and refuses another', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+
+ const allowed = await s.options('/api/v1/masks', {
+ headers: {
+ origin: ALLOWED,
+ 'access-control-request-method': 'POST',
+ 'access-control-request-headers': 'authorization, content-type',
+ },
+ });
+ assert.strictEqual(allowed.status, 204);
+ assert.strictEqual(allowed.headers.get('access-control-allow-origin'), ALLOWED);
+ assert.match(allowed.headers.get('access-control-allow-headers'), /authorization/i);
+ assert.strictEqual(allowed.headers.get('access-control-allow-credentials'), null,
+ 'a cross-origin caller is authenticated by a Bearer token, never by the cookie');
+
+ const refused = await s.options('/api/v1/masks', { headers: { origin: OTHER } });
+ assert.strictEqual(refused.status, 204);
+ assert.strictEqual(refused.headers.get('access-control-allow-origin'), null);
+
+ const read = await s.get('/api/v1/masks', { headers: { origin: OTHER } });
+ assert.strictEqual(read.status, 401);
+ assert.strictEqual(read.headers.get('access-control-allow-origin'), null);
+});
+
+test('a Bearer token is accepted together with a first-party Origin', 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,
+ headers: { origin: ALLOWED },
+ json: { purpose: 'shopping' },
+ });
+ assert.strictEqual(made.status, 201);
+ assert.strictEqual(made.headers.get('access-control-allow-origin'), ALLOWED);
+});
+
+test('the session cookie reads the person\'s mail, and a write must come from openvibe.email', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const cookie = s.network.userToken(alice);
+
+ const session = await s.get('/api/v1/masks', { cookie });
+ assert.strictEqual(session.status, 200, 'the cookie is a session on openvibe.email');
+
+ const crossSite = await s.post('/api/v1/masks', {
+ cookie,
+ origin: OTHER,
+ secFetchSite: 'cross-site',
+ json: { purpose: 'stolen' },
+ });
+ assert.strictEqual(crossSite.status, 403);
+ assert.strictEqual(crossSite.body.code, 'mail.cross_site');
+
+ const sameSite = await s.post('/api/v1/masks', {
+ cookie,
+ origin: 'https://openvibe.email',
+ secFetchSite: 'same-origin',
+ json: { purpose: 'mine' },
+ });
+ assert.strictEqual(sameSite.status, 201);
+
+ // The fetch metadata alone is enough, even when the Origin header is not sent (same-origin GET/POST in some
+ // browsers); a cross-site one is still refused.
+ const metadataOnly = await s.post('/api/v1/masks', {
+ cookie,
+ secFetchSite: 'same-origin',
+ json: { purpose: 'mine too' },
+ });
+ assert.strictEqual(metadataOnly.status, 201);
+});
+
+test('a bad Bearer token is not downgraded to the session cookie', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const cookie = s.network.userToken(alice);
+
+ const res = await s.get('/api/v1/masks', { cookie, bearer: 'not.a.token' });
+ assert.strictEqual(res.status, 401);
+});
+
+test('/me/mail lists all three kinds with state and counts, and the resources are the Contracts shapes', 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: { purpose: 'shopping' } })).body.mask;
+ const burner = (await s.post('/api/v1/burners', { as: alice, json: { expires: '1h' } })).body.burner;
+ const inbox = (await s.post('/api/v1/agent-inboxes', { as: alice, json: { agent_id: 'weather' } })).body.agent_inbox;
+
+ s.mail.deliver({ mailboxAddress: burner.address, to: burner.address, subject: 'code', text: 'Your code is 123456' });
+ assert.strictEqual((await s.get(`/api/v1/burners/${burner.id}/code`, { as: alice })).status, 200,
+ 'reading the code observes the message for the count');
+
+ const mine = await s.get('/api/v1/me/mail', { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.body.masks.length, 1);
+ assert.strictEqual(mine.body.burners.length, 1);
+ assert.strictEqual(mine.body.agent_inboxes.length, 1);
+ assert.strictEqual(mine.body.counts.masks.total, 1);
+ assert.strictEqual(mine.body.counts.burners.live, 1);
+ assert.strictEqual(mine.body.counts.agent_inboxes.total, 1);
+ assert.strictEqual(mine.body.burners[0].counts.messages, 1);
+
+ assert.ok(contracts.validate('mail.mask@1', mine.body.masks[0].mask).valid, 'mask is mail.mask@1');
+ assert.ok(contracts.validate('mail.burner@1', mine.body.burners[0].burner).valid, 'burner is mail.burner@1');
+ assert.ok(contracts.validate('mail.agent-inbox@1', mine.body.agent_inboxes[0].agent_inbox).valid, 'inbox is mail.agent-inbox@1');
+ assert.strictEqual(mine.body.masks[0].mask.owner, alice.subject);
+
+ assert.strictEqual(mine.body.masks[0].state, 'active');
+ assert.strictEqual(mine.body.burners[0].state, 'live');
+ assert.strictEqual(mine.body.agent_inboxes[0].state, 'active');
+ assert.strictEqual(mine.body.masks[0].address, mine.body.masks[0].mask.address);
+ assert.strictEqual(mine.body.masks[0].purpose, 'shopping');
+ assert.strictEqual(mine.body.agent_inboxes[0].purpose, null);
+ assert.ok(mine.body.masks[0].mask.id && mine.body.masks[0].mask.address);
+ assert.strictEqual(mask.id, mine.body.masks[0].mask.id);
+ assert.strictEqual(burner.id, mine.body.burners[0].burner.id);
+ assert.strictEqual(inbox.id, mine.body.agent_inboxes[0].agent_inbox.id);
+
+ const text = JSON.stringify(mine.body);
+ assert.ok(!text.includes('123456'), 'no code in the listing');
+ assert.ok(!text.includes('Your code is'), 'no body in the listing');
+});
+
+test('/me/mail needs a person', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const res = await s.get('/api/v1/me/mail');
+ assert.strictEqual(res.status, 401);
+});
+
+test('a mask can be given a life, and POST /masks/:id/disable turns it off', 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: { purpose: 'shop', expires_days: 7 } });
+ assert.strictEqual(made.status, 201);
+ assert.ok(contracts.validate('mail.mask@1', made.body.mask).valid, 'the create answer is still mail.mask@1');
+
+ const listed = await s.get('/api/v1/me/mail', { as: alice });
+ const item = listed.body.masks[0];
+ assert.ok(item.expires_at, 'the listing carries the expiry');
+ const days = (new Date(item.expires_at).getTime() - Date.now()) / 86400000;
+ assert.ok(days > 6.9 && days <= 7, 'the expiry is seven days away');
+
+ const off = await s.post(`/api/v1/masks/${made.body.mask.id}/disable`, { as: alice });
+ assert.strictEqual(off.status, 200);
+ assert.strictEqual(off.body.mask.enabled, false);
+ assert.ok(s.mail.calls.invalidateCaches >= 1);
+
+ const crossed = await s.post(`/api/v1/masks/${made.body.mask.id}/disable`, { as: alice });
+ assert.strictEqual(crossed.status, 404);
+});
+
+test('expires_days is a whole number from 1 to 3650', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ for (const value of [0, -1, 1.5, 'soon', 3651]) {
+ const res = await s.post('/api/v1/masks', { as: alice, json: { purpose: 'x', expires_days: value } });
+ assert.strictEqual(res.status, 400, `expires_days ${value} is refused`);
+ assert.strictEqual(res.body.code, 'mail.bad_expiry');
+ }
+});
@@ -65,6 +65,9 @@ async function boot(opts = {}) {
@@ -65,6 +65,9 @@ async function boot(opts = {}) {
if (o.as) headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (o.as) headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
if (o.token) headers['x-mail-burner-token'] = o.token;
if (o.token) headers['x-mail-burner-token'] = o.token;
+ if (o.cookie) headers.cookie = `ov_token=${o.cookie}`;
+ if (o.origin) headers.origin = o.origin;
+ if (o.secFetchSite) headers['sec-fetch-site'] = o.secFetchSite;
let body;
let body;
if (o.json !== undefined) {
if (o.json !== undefined) {
body = JSON.stringify(o.json);
body = JSON.stringify(o.json);
@@ -88,6 +91,7 @@ async function boot(opts = {}) {
@@ -88,6 +91,7 @@ async function boot(opts = {}) {
get: (p, o) => call('GET', p, o),
get: (p, o) => call('GET', p, o),
post: (p, o) => call('POST', p, o),
post: (p, o) => call('POST', p, o),
del: (p, o) => call('DELETE', p, o),
del: (p, o) => call('DELETE', p, o),
+ options: (p, o) => call('OPTIONS', p, o),
close: async () => {
close: async () => {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
await network.close();
await network.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+
+/**
+ * deploy/nginx/openvibe.email.conf is the public door. This test is the static guard: the three server_name lines,
+ * the TLS files for the name, and a proxy list limited to the API and the Network sign-in routes (plus the two
+ * machine answers). `/` is the placeholder and never reaches the service.
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+
+const FILE = path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.email.conf');
+const CONF = fs.readFileSync(FILE, 'utf8');
+
+/** location → block body (the file has no nested braces inside a location). */
+function locations() {
+ const out = new Map();
+ for (const match of CONF.matchAll(/location\s+(?:=\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {
+ out.set(match[1], match[2]);
+ }
+ return out;
+}
+
+test('openvibe.email has the three house server_name lines and the existing certificate', () => {
+ assert.match(CONF, /server_name openvibe\.email www\.openvibe\.email;/);
+ assert.match(CONF, /server_name www\.openvibe\.email;/);
+ assert.match(CONF, /server_name openvibe\.email;/);
+ assert.match(CONF, /ssl_certificate\s+\/etc\/letsencrypt\/live\/openvibe\.email\/fullchain\.pem;/);
+ assert.match(CONF, /ssl_certificate_key\s+\/etc\/letsencrypt\/live\/openvibe\.email\/privkey\.pem;/);
+ assert.match(CONF, /include snippets\/security-txt\.conf;/);
+});
+
+test('only the API, the machine answers and the sign-in routes are proxied', () => {
+ const blocks = locations();
+ const proxied = [...blocks.entries()]
+ .filter(([, body]) => /proxy_pass\s+http:\/\/127\.0\.0\.1:5250;/.test(body))
+ .map(([name]) => name)
+ .sort();
+ assert.deepStrictEqual(proxied, ['/api/health', '/api/ready', '/api/v1/', '/auth/', '/auth/me'].sort());
+
+ // A proxy anywhere else (a generic /api/ or / catch-all) would expose more than the door above.
+ assert.strictEqual((CONF.match(/proxy_pass\s+/g) || []).length, proxied.length,
+ 'every proxy_pass belongs to one of the five locations');
+});
+
+test('/ is the placeholder and unknown paths are not proxied', () => {
+ const blocks = locations();
+ const root = blocks.get('/');
+ assert.ok(root, 'there is an exact / location');
+ assert.match(root, /return 200 /);
+ assert.doesNotMatch(root, /proxy_pass/);
+ assert.match(blocks.get('/api/'), /return 404;/);
+ assert.doesNotMatch(blocks.get('/api/'), /proxy_pass/);
+ assert.match(CONF, /location ~\* \^\/\(metrics\|internal\)\(/);
+});
+
+test('the API forwards the client address the way the house does behind Cloudflare', () => {
+ assert.match(CONF, /proxy_set_header Host \$host;/);
+ assert.match(CONF, /proxy_set_header X-Real-IP \$remote_addr;/);
+ assert.match(CONF, /proxy_set_header X-Forwarded-For \$remote_addr;/);
+ assert.match(CONF, /proxy_set_header CF-Connecting-IP \$remote_addr;/);
+ assert.match(CONF, /proxy_set_header X-Forwarded-Proto \$scheme;/);
+ assert.match(CONF, /cloudflare-realip\.conf/, 'the global Cloudflare real-IP include is documented');
+ assert.match(CONF, /Strict-Transport-Security/);
+ assert.match(CONF, /X-Content-Type-Options nosniff/);
+});
@@ -48,3 +48,22 @@ test('a failed delete keeps the burner, so the next run deletes the mail', async
@@ -48,3 +48,22 @@ test('a failed delete keeps the burner, so the next run deletes the mail', async
assert.ok(broken.calls.invalidateCaches > before, 'the expired address still leaves the cache even though its mail was not deleted');
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);
assert.strictEqual(await s.db.value('SELECT count(*)::int FROM mail_burners WHERE id = $1', [made.body.burner.id]), 1);
});
});
+
+test('a mask whose life has run out is disabled, and the mail server is told once', 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: { purpose: 'one purchase', expires_days: 1 } });
+ assert.strictEqual(made.status, 201);
+ await s.db.exec("UPDATE mail_masks SET expires_at = now() - interval '1 minute' WHERE id = $1", [made.body.mask.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.masksDisabled, 1);
+ assert.ok(s.mail.calls.invalidateCaches > before, 'the mail server forgot the disabled address');
+ assert.strictEqual(await s.db.value('SELECT enabled FROM mail_masks WHERE id = $1', [made.body.mask.id]), false);
+
+ const listed = await s.get('/api/v1/me/mail', { as: alice });
+ assert.strictEqual(listed.body.masks[0].state, 'expired');
+});
@@ -1,12 +1,14 @@
@@ -1,12 +1,14 @@
'use strict';
'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.
+ * Start the pinned Stalwart binary against the test PostgreSQL, 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.
+ * Blobs go to a temporary local directory by default (M1's fs store); set STALWART_BLOB_STORE=s3 to run the same
+ * acceptance test against MinIO. 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 only when the S3 store is selected.
*/
*/
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -40,6 +42,8 @@ function testEnv(overrides = {}) {
@@ -40,6 +42,8 @@ function testEnv(overrides = {}) {
STALWART_URL: 'http://127.0.0.1:18080',
STALWART_URL: 'http://127.0.0.1:18080',
STALWART_ADMIN_USER: 'admin',
STALWART_ADMIN_USER: 'admin',
STALWART_ADMIN_PASSWORD: 'ov-test-admin-password',
STALWART_ADMIN_PASSWORD: 'ov-test-admin-password',
+ STALWART_BLOB_STORE: process.env.STALWART_BLOB_STORE || 'fs',
+ STALWART_BLOB_PATH: process.env.STALWART_BLOB_PATH || '',
...overrides,
...overrides,
};
};
}
}
@@ -80,11 +84,18 @@ async function disableInboundThrottles(client) {
@@ -80,11 +84,18 @@ async function disableInboundThrottles(client) {
async function start({ env = testEnv(), binary = null } = {}) {
async function start({ env = testEnv(), binary = null } = {}) {
const bin = binary || binaryPath(env);
const bin = binary || binaryPath(env);
if (!fs.existsSync(bin)) throw new Error(`STALWART_BIN not found: ${bin} (download the pinned release first)`);
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 blobStore = String(env.STALWART_BLOB_STORE || 'fs').toLowerCase();
+ if (blobStore === 's3') {
+ 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 s3 acceptance run needs the S3/MinIO target`);
+ }
}
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mail-stalwart-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mail-stalwart-'));
+ const blobDir = blobStore === 'fs'
+ ? (env.STALWART_BLOB_PATH || fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mail-blobs-')))
+ : null;
+ if (blobDir) env = { ...env, STALWART_BLOB_PATH: blobDir };
execFileSync(process.execPath, [path.join(ROOT, 'deploy', 'stalwart', 'render.js'), '--out', dir], { env, stdio: 'pipe' });
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 plan = JSON.parse(fs.readFileSync(path.join(dir, 'plan.json'), 'utf8'));
@@ -175,6 +186,7 @@ async function start({ env = testEnv(), binary = null } = {}) {
@@ -175,6 +186,7 @@ async function start({ env = testEnv(), binary = null } = {}) {
return {
return {
child: childState.child,
child: childState.child,
dir,
dir,
+ blobDir,
logs,
logs,
url,
url,
smtpPort: Number(env.STALWART_SMTP_PORT),
smtpPort: Number(env.STALWART_SMTP_PORT),
@@ -184,6 +196,7 @@ async function start({ env = testEnv(), binary = null } = {}) {
@@ -184,6 +196,7 @@ async function start({ env = testEnv(), binary = null } = {}) {
async stop() {
async stop() {
await stopState(childState);
await stopState(childState);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
+ if (blobDir) fs.rmSync(blobDir, { recursive: true, force: true });
},
},
};
};
}
}
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
/**
/**
- * The M1 acceptance test against the real Stalwart binary, PostgreSQL and MinIO.
+ * The M1 acceptance test against the real Stalwart binary and PostgreSQL. Blobs go to a temporary local directory
+ * (M1's fs store); with STALWART_BLOB_STORE=s3 the same run uses MinIO.
*
*
* It is the one place the directory views meet the server that reads them:
* It is the one place the directory views meet the server that reads them:
* * SMTP to a burner lands, and /code returns the code;
* * SMTP to a burner lands, and /code returns the code;
@@ -12,9 +13,11 @@
@@ -12,9 +13,11 @@
*/
*/
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert');
const assert = require('node:assert');
+const fs = require('fs');
const http = require('http');
const http = require('http');
const { start, testEnv, waitFor, sleep } = require('./helpers/stalwart');
const { start, testEnv, waitFor, sleep } = require('./helpers/stalwart');
+const { createClient } = require('../../deploy/stalwart/apply');
const { ensureDatabase, resetSchema, urlFor } = require('./helpers/pg');
const { ensureDatabase, resetSchema, urlFor } = require('./helpers/pg');
const { ensureBucket, listKeys } = require('./helpers/s3');
const { ensureBucket, listKeys } = require('./helpers/s3');
const { send, sendMany } = require('./helpers/smtp');
const { send, sendMany } = require('./helpers/smtp');
@@ -37,6 +40,36 @@ const { createApp } = require('../../server/app');
@@ -37,6 +40,36 @@ const { createApp } = require('../../server/app');
const { createStalwartClient } = require('../../server/stalwart/client');
const { createStalwartClient } = require('../../server/stalwart/client');
const { runDue } = require('../../server/jobs/purge');
const { runDue } = require('../../server/jobs/purge');
+function blobFiles() {
+ if (!stalwart || !stalwart.blobDir) return 0;
+ let count = 0;
+ for (const entry of fs.readdirSync(stalwart.blobDir, { recursive: true, withFileTypes: true })) {
+ if (entry.isFile()) count += 1;
+ }
+ return count;
+}
+
+/**
+ * Ask the mail server to run its blob purge now. On its normal schedule it runs once a day, so the acceptance test
+ * would otherwise see the message's blob for up to a day after the account is gone.
+ */
+async function purgeBlobs() {
+ const client = createClient({
+ url: stalwart.url,
+ user: stalwart.env.STALWART_ADMIN_USER,
+ password: stalwart.env.STALWART_ADMIN_PASSWORD,
+ });
+ await client.one('x:Task/set', {
+ create: {
+ purge: {
+ '@type': 'StoreMaintenance',
+ maintenanceType: 'purgeBlob',
+ status: { '@type': 'Pending' },
+ },
+ },
+ }, 'pb');
+}
+
function captureLog() {
function captureLog() {
const lines = [];
const lines = [];
const push = (...a) => lines.push(a.map((x) => String(x)).join(' '));
const push = (...a) => lines.push(a.map((x) => String(x)).join(' '));
@@ -92,7 +125,7 @@ async function waitForAgentMessages(id, o = {}) {
@@ -92,7 +125,7 @@ async function waitForAgentMessages(id, o = {}) {
test.before(async () => {
test.before(async () => {
await ensureDatabase(env);
await ensureDatabase(env);
await resetSchema(env);
await resetSchema(env);
- ensureBucket();
+ if (String(env.STALWART_BLOB_STORE || 'fs').toLowerCase() === 's3') ensureBucket();
stalwart = await start({ env });
stalwart = await start({ env });
network = await startNetwork();
network = await startNetwork();
@@ -173,7 +206,12 @@ test('SMTP to a burner lands, the code endpoint returns it, and the log has no b
@@ -173,7 +206,12 @@ test('SMTP to a burner lands, the code endpoint returns it, and the log has no b
assert.ok(!('subject' in events[0].envelope.payload), 'mail.received carries no subject');
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(!('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');
+ if (stalwart.env.STALWART_BLOB_STORE === 's3') {
+ assert.ok(listKeys().length > 0, 'Stalwart wrote the message into the S3 blob store');
+ } else {
+ assert.ok(blobFiles() > 0, 'Stalwart wrote the message onto the local filesystem blob store');
+ assert.match(stalwart.blobDir, /ov-mail-blobs-/);
+ }
});
});
test('a burner made for one message stops accepting after the first', async () => {
test('a burner made for one message stops accepting after the first', async () => {
@@ -252,10 +290,18 @@ test('after expiry the burner bounces and its mail is deleted', async () => {
@@ -252,10 +290,18 @@ test('after expiry the burner bounces and its mail is deleted', async () => {
await send({ port: stalwart.smtpPort, to: burner.address, subject: 'code', text: 'Your code is 778899' });
await send({ port: stalwart.smtpPort, to: burner.address, subject: 'code', text: 'Your code is 778899' });
await waitForCode(burner.id, { as: alice });
await waitForCode(burner.id, { as: alice });
+ const blobsBefore = stalwart.env.STALWART_BLOB_STORE === 's3' ? listKeys().length : blobFiles();
+ assert.ok(blobsBefore > 0, 'the message has a blob before the purge');
+
await store.db.exec("UPDATE mail_burners SET expires_at = now() - interval '1 second' WHERE id = $1", [burner.id]);
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() });
const report = await runDue({ db: store.db, stalwart: built.ctx.stalwart, events: built.ctx.events, now: () => Date.now() });
assert.strictEqual(report.expired, 1);
assert.strictEqual(report.expired, 1);
+ await purgeBlobs();
+ const blobCount = () => (stalwart.env.STALWART_BLOB_STORE === 's3' ? listKeys().length : blobFiles());
+ await waitFor(async () => blobCount() < blobsBefore, { timeoutMs: 30_000, what: 'the blob purge' });
+ assert.ok(blobCount() < blobsBefore, 'the blob purge removed the burner message blob');
+
await assert.rejects(
await assert.rejects(
send({ port: stalwart.smtpPort, to: burner.address, subject: 'late', text: 'too late' }),
send({ port: stalwart.smtpPort, to: burner.address, subject: 'late', text: 'too late' }),
(err) => err.refused === true && err.smtp && err.smtp.code === 550,
(err) => err.refused === true && err.smtp && err.smtp.code === 550,
@@ -2,9 +2,10 @@
@@ -2,9 +2,10 @@
'use strict';
'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.
+ * The Stalwart acceptance run. It is separate from `npm test` because it needs the pinned binary and PostgreSQL;
+ * M1's default blob store is the local filesystem, so no object store is needed. CI starts PostgreSQL and runs this
+ * once with fs blobs and once with `STALWART_BLOB_STORE=s3` against MinIO. A workstation can use
+ * `npm run test:stalwart` after fetching the binary and pointing the STALWART_* variables at its database.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -17,8 +18,10 @@ function missing() {
@@ -17,8 +18,10 @@ function missing() {
const bin = process.env.STALWART_BIN || path.join(ROOT, '.cache', 'stalwart', 'stalwart');
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 (!fs.existsSync(bin)) out.push(`STALWART_BIN (run: node scripts/fetch-stalwart.js)`);
if (!process.env.STALWART_DB_PASSWORD) out.push('STALWART_DB_PASSWORD');
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);
+ if (String(process.env.STALWART_BLOB_STORE || 'fs').toLowerCase() === 's3') {
+ 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;
return out;
}
}