openvibers/openvibe.mail Commits

Mail: public front door, fs blobs by default, and the page's API (#2)

OP OpenVibers committed 6 days ago · 129097fb5e · 29 files, +1354 −219

- fs blob store is the M1 default under /var/lib/openvibe-stalwart/blobs; s3 stays available via
  STALWART_BLOB_STORE=s3 (render.js, apply.js, config.toml, INSTALL)
- public vhost deploy/nginx/openvibe.email.conf: TLS, the house Cloudflare/X-Forwarded headers, the
  placeholder at /, and only the API, /api/health, /api/ready and /auth/* proxied
- CORS from the Contracts manifest origins; same-site Network session cookie via openvibe-sdk/sso;
  a cookie-authenticated write must come from openvibe.email (Sec-Fetch-Site/Origin)
- GET /api/v1/me/mail (masks, burners, agent inboxes with address, purpose, state and counts),
  POST /api/v1/masks/:id/disable, masks with expires_days
- contracts v0.329.0; tests for CORS, cookie writes, /me/mail, the nginx paths, fs blobs end to end

Changes

ddiff --git a/.env.example b/.env.example
iindex 565d230..9c87f15 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=5432
STALWART_DB_NAME=ov_mail
STALWART_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=2
STALWART_BLOB_ENDPOINT=
STALWART_BLOB_BUCKET=
STALWART_BLOB_ACCESS_KEY_ID=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex ba170e9..f49ae51 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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.04
services:
postgres:
@@@ -46,12 +91,6 @@ jobs:
node-version: 22.22.1
cache: npm
- run: npm ci --no-audit --no-fund
-- - name: syntax
-- run: |
-- find server scripts test deploy -name '*.js' -not -path '*/node_modules/*' -print0 \
-- | xargs -0 -n1 node --check
-- - name: unit tests (PGlite, no services)
-- run: npm test
- name: download the pinned Stalwart (sha256 verified)
run: node scripts/fetch-stalwart.js
- name: Stalwart acceptance (PostgreSQL + MinIO + real SMTP)
@@@ -62,6 +101,7 @@ jobs:
STALWART_DB_USER: ov
STALWART_DB_PASSWORD: ov-ci-password
STALWART_TEST_DB: ov_mail_test
++ STALWART_BLOB_STORE: s3
STALWART_BLOB_ENDPOINT: http://127.0.0.1:9000
STALWART_BLOB_BUCKET: ov-mail
STALWART_BLOB_REGION: us-east-1
ddiff --git a/README.md b/README.md
iindex 211b446..acec62a 100644
---- a/README.md
++++ b/README.md
@@@ -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 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
@@@ -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: n
memory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the liveness
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
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
--`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 a
directory-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
```sh
npm test # unit: PGlite, an in-process Network and an in-memory mail server
npm run test:pg # the same through the PostgreSQL container
--npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL + MinIO
++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 p
migrations/ NNNN_*.sql (expand-only), including the directory view Stalwart reads
deploy/stalwart/ config.toml, VERSION, render.js, apply.js
deploy/systemd/ the memory-capped Stalwart slice and both units
++deploy/nginx/ openvibe.email.conf — the public door (API, health, ready, /auth)
deploy/init-db.js apply migrations before systemd starts the service
test/ unit tests and the real-Stalwart acceptance test
docs/ INSTALL.md and DNS.md
ddiff --git a/STATUS.json b/STATUS.json
iindex d0b5484..e649552 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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
++ }
}
ddiff --git a/deploy/nginx/openvibe.email.conf b/deploy/nginx/openvibe.email.conf
nnew file mode 100644
iindex 0000000..df3c20d
---- /dev/null
++++ b/deploy/nginx/openvibe.email.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/stalwart/apply.js b/deploy/stalwart/apply.js
iindex acb0d4a..b9fb754 100644
---- a/deploy/stalwart/apply.js
++++ b/deploy/stalwart/apply.js
@@@ -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 };
ddiff --git a/deploy/stalwart/config.toml b/deploy/stalwart/config.toml
iindex fc6c6dc..bd8aa08 100644
---- a/deploy/stalwart/config.toml
++++ b/deploy/stalwart/config.toml
@@@ -33,8 +33,17 @@ ssl = false
pool_max_connections = 20
[blob_store]
--# B2 in production (an S3-compatible endpoint), MinIO in CI. The endpoint is a custom S3 region to Stalwart.
--kind = "s3"
++# 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"
ddiff --git a/deploy/stalwart/render.js b/deploy/stalwart/render.js
iindex 7529a98..9b82994 100644
---- a/deploy/stalwart/render.js
++++ b/deploy/stalwart/render.js
@@@ -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 },
ddiff --git a/docs/INSTALL.md b/docs/INSTALL.md
iindex ff75266..5ef321b 100644
---- a/docs/INSTALL.md
++++ b/docs/INSTALL.md
@@@ -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=5432
STALWART_DB_NAME=ov_mail
STALWART_DB_USER=ov_mail
STALWART_DB_PASSWORD=REPLACE
--STALWART_BLOB_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
--STALWART_BLOB_BUCKET=openvibe-mail
--STALWART_BLOB_REGION=eu-central-003
--STALWART_BLOB_ACCESS_KEY_ID=REPLACE
--STALWART_BLOB_SECRET_KEY=REPLACE
++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_PASSWORD
MAIL_EVENTS_URL=http://127.0.0.1:4300
MAIL_EVENTS_CLIENT_ID=mail
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
--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 a
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
the MX, then test from outside:
@@@ -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()"
```
--## 10. Verify
++## 11. Verify
```sh
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
```
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; do
not raise `MemoryMax` without measuring the spam filter and a busy mailbox first.
--## 12. Rollback
++## 13. Rollback
```sh
sudo systemctl stop openvibe-mail.service openvibe-stalwart.service
sudo systemctl disable openvibe-mail.service openvibe-stalwart.service
```
--The data stays in PostgreSQL (`ov_mail`) and B2. Removing the DNS MX records stops new mail; mail already queued on
++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.
ddiff --git a/migrations/0006_mask_expiry.sql b/migrations/0006_mask_expiry.sql
nnew file mode 100644
iindex 0000000..f1970d5
---- /dev/null
++++ b/migrations/0006_mask_expiry.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 640bd1e..882a9f6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex f14f0ce..d32eb72 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex 79c8bb9..c1741c1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 00195d6..bce35a4 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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,
ddiff --git a/server/domain/burners.js b/server/domain/burners.js
iindex c370915..bd8efb3 100644
---- a/server/domain/burners.js
++++ b/server/domain/burners.js
@@@ -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_at
FROM 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,
ddiff --git a/server/domain/masks.js b/server/domain/masks.js
iindex 8f44ec2..cf34c2e 100644
---- a/server/domain/masks.js
++++ b/server/domain/masks.js
@@@ -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 = n
for (let attempt = 0; attempt < 6; attempt++) {
const address = maskAddress(words, config.addresses.mask);
const row = await t.maybe(sql`
-- INSERT INTO mail_masks (id, owner_subject, address, label, purpose, enabled)
-- VALUES (${`msk_${ids.ulid(now())}`}, ${owner}, ${address}, ${label || null}, ${purpose || null}, true)
++ 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_at
FROM 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_at
FROM 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 = false
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 (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,
++};
ddiff --git a/server/domain/messages.js b/server/domain/messages.js
iindex c64a5c3..688978a 100644
---- a/server/domain/messages.js
++++ b/server/domain/messages.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 505a416..acf03a4 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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.');
ddiff --git a/server/http/cors.js b/server/http/cors.js
nnew file mode 100644
iindex 0000000..4b9a82d
---- /dev/null
++++ b/server/http/cors.js
@@@ -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 };
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 9c19839..5d916e6 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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 };
ddiff --git a/server/jobs/purge.js b/server/jobs/purge.js
iindex 77e9fa0..088a8bb 100644
---- a/server/jobs/purge.js
++++ b/server/jobs/purge.js
@@@ -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}`);
ddiff --git a/test/deploy.test.js b/test/deploy.test.js
nnew file mode 100644
iindex 0000000..33056b4
---- /dev/null
++++ b/test/deploy.test.js
@@@ -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');
++});
ddiff --git a/test/frontdoor.test.js b/test/frontdoor.test.js
nnew file mode 100644
iindex 0000000..650cca2
---- /dev/null
++++ b/test/frontdoor.test.js
@@@ -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');
++ }
++});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex c172bc9..48e487e 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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();
ddiff --git a/test/nginx.test.js b/test/nginx.test.js
nnew file mode 100644
iindex 0000000..b68cf45
---- /dev/null
++++ b/test/nginx.test.js
@@@ -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/);
++});
ddiff --git a/test/purge.test.js b/test/purge.test.js
iindex 79534a2..3345973 100644
---- a/test/purge.test.js
++++ b/test/purge.test.js
@@@ -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.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');
++});
ddiff --git a/test/stalwart/helpers/stalwart.js b/test/stalwart/helpers/stalwart.js
iindex ab29b33..88f6ff2 100644
---- a/test/stalwart/helpers/stalwart.js
++++ b/test/stalwart/helpers/stalwart.js
@@@ -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 });
},
};
}
ddiff --git a/test/stalwart/integration.test.js b/test/stalwart/integration.test.js
iindex c09625b..8d0d507 100644
---- a/test/stalwart/integration.test.js
++++ b/test/stalwart/integration.test.js
@@@ -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 b
assert.ok(!('subject' in events[0].envelope.payload), 'mail.received carries no subject');
assert.ok(!('body' in events[0].envelope.payload), 'mail.received carries no body');
-- assert.ok(listKeys().length > 0, 'Stalwart wrote the message into the S3 blob store');
++ 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,
ddiff --git a/test/stalwart/run.js b/test/stalwart/run.js
iindex f80cbf6..5e34e8e 100644
---- a/test/stalwart/run.js
++++ b/test/stalwart/run.js
@@@ -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;
}