openvibers/openvibe.mail Commits

INSTALL says what production needed; /release.json for open tabs (#8)

OP OpenVibers committed 6 days ago · a478e3f384 · 6 files, +61 −4

INSTALL, from the 2026-11-07 install: stalwart.env carries STALWART_RECOVERY_ADMIN in normal mode too (else the
management API refuses and /api/ready is 503); the migrations run before the second apply (it answers 500
without Mail's tables); on an ovhost host the inventory deploys the Node service and installs its unit (house
user); the OAuth client's credentials come from Network's service-principal rotate. Outside SMTP tests from a
home connection fail on the ISP's port 25 block: count outside SYNs on the host, and prove delivery over
loopback SMTP to a fresh burner. DNS.md records what was published (no rua until dmarc@ exists, no AAAA).

/release.json: openvibe-shared/release, proxied by nginx (GET/HEAD), so the page updates in place like every
other site.

Changes

ddiff --git a/deploy/nginx/openvibe.email.conf b/deploy/nginx/openvibe.email.conf
iindex e2a7063..228565b 100644
---- a/deploy/nginx/openvibe.email.conf
++++ b/deploy/nginx/openvibe.email.conf
@@@ -100,6 +100,12 @@ server {
proxy_pass http://127.0.0.1:5250;
}
++ # The release manifest open tabs poll (no-cache, set by the app) to update in place.
++ location = /release.json {
++ limit_except GET HEAD { deny all; }
++ 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/ {
ddiff --git a/docs/DNS.md b/docs/DNS.md
iindex d2d5123..26074a0 100644
---- a/docs/DNS.md
++++ b/docs/DNS.md
@@@ -23,7 +23,12 @@ name.
sending, replace it with the sending hosts and keep `-all`; do not weaken DMARC to `p=none` for convenience.
`rua` needs a mailbox that exists. Provision `[email protected]` before publishing, or point `rua` at an
--address the owner already reads. A report address that bounces loses the reports.
++address the owner already reads. A report address that bounces loses the reports. Production (2026-11-07) publishes
++the DMARC records **without** `rua` (`v=DMARC1; p=reject; adkim=s; aspf=s`) until that mailbox exists; add it then.
++
++Published 2026-11-07 (Cloudflare, all TTL 300): `mail.openvibe.email` A (DNS only, the host's IPv4; no AAAA, since
++Stalwart listens on IPv4 only), MX 10 on `openvibe.email`, `mask.openvibe.email` and `burn1`–`burn3`, and SPF
++`-all` plus DMARC reject on each of those subdomains. The apex already had SPF `-all`, DMARC reject and CAA.
### `mask.openvibe.email` (site masks)
ddiff --git a/docs/INSTALL.md b/docs/INSTALL.md
iindex 1823e1d..35889ab 100644
---- a/docs/INSTALL.md
++++ b/docs/INSTALL.md
@@@ -94,6 +94,10 @@ STALWART_DB_USER=ov_mail
STALWART_DB_PASSWORD=REPLACE
STALWART_BLOB_STORE=fs
STALWART_BLOB_PATH=/var/lib/openvibe-stalwart/blobs
++# The admin in normal mode too: Stalwart 1.0.3 defines its admin from this variable whether or not recovery mode
++# is on. Without it the management API answers "You have to authenticate first" and /api/ready stays 503.
++# The same password as STALWART_ADMIN_PASSWORD in mail.env (production, 2026-11-07).
++STALWART_RECOVERY_ADMIN=admin:REPLACE_WITH_THE_SAME_PASSWORD_AS_STALWART_ADMIN_PASSWORD
STALWART_BLOB_DEPTH=2
# Optional, only when STALWART_BLOB_STORE=s3:
#STALWART_BLOB_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
@@@ -201,6 +205,10 @@ sudo systemctl daemon-reload
sudo systemctl enable --now openvibe-stalwart.slice openvibe-stalwart.service
```
++Before applying again, run the service's migrations (step 8's `deploy/init-db.js`, or the first `ovhost deploy
++mail`, which runs them at boot): the SQL directory reads Mail's tables, and the second apply answers 500 while they
++do not exist yet (production, 2026-11-07).
++
Now the normal start has written the built-in roles. Apply again; this sets `Authentication.defaultUserRoleIds`
(the `User` role) and reloads the settings, so an account the directory creates receives mail instead of being
refused with "This account is not authorized to receive email":
@@@ -232,6 +240,22 @@ 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.
++On a host run by ovhost (production), the inventory entry does this step instead: `ovhost service add mail`, then
++`ovhost deploy mail` builds a release, installs `openvibe-mail.service` and starts it (the unit runs as the house
++user, `ubuntu`, like every Node service; Stalwart alone runs as `openvibe`). Do not rsync into `/opt/openvibe.mail`
++by hand there.
++
++The OAuth client comes from Network: once Network has seeded the `mail` client (it ships with Network), write its
++credentials into Mail's env from Network's checkout, then restart Mail:
++
++```sh
++cd /opt/openvibe.network
++sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate mail --write-env /etc/openvibe/mail.env
++sudo systemctl restart openvibe-mail
++```
++
++It writes `OV_OAUTH_CLIENT_ID` and `OV_OAUTH_CLIENT_SECRET` (0600) and prints neither. Back up `mail.env` first.
++
## 9. The public door (nginx)
The parked `openvibe.email` block in `/etc/nginx/sites-enabled/parked-domains.conf` redirects the name to Chat.
@@@ -263,7 +287,9 @@ secret in `OV_OAUTH_CLIENT_SECRET` in `/etc/openvibe/mail.env`, or sign-in canno
## 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:
++the MX, then test from outside (a home connection usually cannot: most ISPs block outbound port 25, so
++`connect` times out even to Gmail's MX; watch for outside SYNs on the host instead,
++`sudo timeout 240 tcpdump -n -i any 'tcp dst port 25 and tcp[tcpflags] & (tcp-syn|tcp-ack) == tcp-syn and not src net 127.0.0.0/8' -c 3`):
```sh
dig +short MX openvibe.email
@@@ -280,7 +306,10 @@ 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. Check that
++create a burner and use `GET /api/v1/burners/:id/code`). With no outside sender at hand, the host itself can deliver
++one over loopback SMTP: a relay to a foreign domain must answer `550 5.7.1 Relay not allowed`, an unknown local
++mailbox `550 5.1.1 Mailbox does not exist`, and a code mail to a fresh burner must come back from
++`GET /api/v1/burners/:id/code` (production, 2026-11-07: code 482913, read back in under a second). A body must never appear in either journal. Check that
the public door answers:
```sh
ddiff --git a/server/app.js b/server/app.js
iindex 7a11f95..734fac1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createEvents } = require('./events');
const { createSsoClient } = require('openvibe-sdk/sso');
const VERSION = require('../package.json').version;
++const ROOT = require('path').join(__dirname, '..');
/**
* opts: config, store, stalwart, events, now (clock), fetchImpl (Network), log.
@@@ -81,6 +82,10 @@ async function createApp(opts = {}) {
const readiness = createServiceReadiness({ db: store.db, config, stalwart, version: VERSION });
app.get('/api/ready', readiness.handler);
++ // GET /release.json (registry.release-manifest@1): the deployed commit, read at boot. Open tabs poll it through
++ // the shared navbar's release-watch and pick up a new release without a hard refresh, as on every other site.
++ require('openvibe-shared/release').createRelease({ service: 'mail', root: ROOT }).mount(app);
++
// 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.
ddiff --git a/test/nginx.test.js b/test/nginx.test.js
iindex f719150..c780143 100644
---- a/test/nginx.test.js
++++ b/test/nginx.test.js
@@@ -37,7 +37,7 @@ test('only the page and its files, the API, the machine answers and the sign-in
.filter(([, body]) => /proxy_pass\s+http:\/\/127\.0\.0\.1:5250;/.test(body))
.map(([name]) => name)
.sort();
-- assert.deepStrictEqual(proxied, ['/', '^/(css|js|shared)/', '/api/health', '/api/ready', '/api/v1/', '/auth/', '/auth/me'].sort());
++ assert.deepStrictEqual(proxied, ['/', '^/(css|js|shared)/', '/api/health', '/api/ready', '/api/v1/', '/auth/', '/auth/me', '/release.json'].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,
ddiff --git a/test/page.test.js b/test/page.test.js
iindex bc5edf6..a4978d4 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -50,3 +50,15 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
const missing = await s.get('/js/nope.js');
assert.strictEqual(missing.status, 404);
});
++
++test('/release.json is the release manifest open tabs poll, never cached', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const r = await s.get('/release.json');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('cache-control') || '', /no-cache/);
++ const m = JSON.parse(r.text);
++ assert.strictEqual(m.service, 'mail');
++ assert.ok(typeof m.release === 'string' && m.release.length > 0, 'the deployed commit');
++ assert.ok(m.packages && m.packages['openvibe-shared'], 'the platform packages it runs');
++});