openvibers/openvibe.mail Commits
INSTALL says what production needed; /release.json for open tabs (#8)
Changes
@@@ -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/ {@@@ -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)@@@ -94,6 +94,10 @@ STALWART_DB_USER=ov_mailSTALWART_DB_PASSWORD=REPLACESTALWART_BLOB_STORE=fsSTALWART_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_PASSWORDSTALWART_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-reloadsudo 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 beingrefused 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 afailure 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. DNSPublish 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`):```shdig +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 thatthe public door answers:```sh@@@ -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.@@@ -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,@@@ -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');++});
@@ -100,6 +100,12 @@ server {
@@ -100,6 +100,12 @@ server {
proxy_pass http://127.0.0.1:5250;
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 API ─────────────────────────────────────────────
# The app answers the CORS preflight and sets the headers; nginx must not add any of its own.
# The app answers the CORS preflight and sets the headers; nginx must not add any of its own.
location /api/v1/ {
location /api/v1/ {
@@ -23,7 +23,12 @@ name.
@@ -23,7 +23,12 @@ name.
sending, replace it with the sending hosts and keep `-all`; do not weaken DMARC to `p=none` for convenience.
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
`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)
### `mask.openvibe.email` (site masks)
@@ -94,6 +94,10 @@ STALWART_DB_USER=ov_mail
@@ -94,6 +94,10 @@ STALWART_DB_USER=ov_mail
STALWART_DB_PASSWORD=REPLACE
STALWART_DB_PASSWORD=REPLACE
STALWART_BLOB_STORE=fs
STALWART_BLOB_STORE=fs
STALWART_BLOB_PATH=/var/lib/openvibe-stalwart/blobs
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
STALWART_BLOB_DEPTH=2
# Optional, only when STALWART_BLOB_STORE=s3:
# Optional, only when STALWART_BLOB_STORE=s3:
#STALWART_BLOB_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
#STALWART_BLOB_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
@@ -201,6 +205,10 @@ sudo systemctl daemon-reload
@@ -201,6 +205,10 @@ sudo systemctl daemon-reload
sudo systemctl enable --now openvibe-stalwart.slice openvibe-stalwart.service
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`
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
(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":
refused with "This account is not authorized to receive email":
@@ -232,6 +240,22 @@ sudo systemctl enable --now openvibe-mail.service
@@ -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
`deploy/init-db.js` runs the migrations through `DATABASE_DIRECT_URL`; the service would also do it at boot, but a
failure here is easier to read. The service adds the domains it knows on start; there is no pool mailbox.
failure here is easier to read. The service adds the domains it knows on start; there is no pool mailbox.
+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)
## 9. The public door (nginx)
The parked `openvibe.email` block in `/etc/nginx/sites-enabled/parked-domains.conf` redirects the name to Chat.
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
@@ -263,7 +287,9 @@ secret in `OV_OAUTH_CLIENT_SECRET` in `/etc/openvibe/mail.env`, or sign-in canno
## 10. DNS
## 10. DNS
Publish the records in [`docs/DNS.md`](DNS.md), keeping the mail A/AAAA records **DNS-only** in Cloudflare. Wait for
Publish the records in [`docs/DNS.md`](DNS.md), keeping the mail A/AAAA records **DNS-only** in Cloudflare. Wait for
-the MX, then test from outside:
+the MX, then test from outside (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
```sh
dig +short MX openvibe.email
dig +short MX openvibe.email
@@ -280,7 +306,10 @@ journalctl -u openvibe-stalwart -u openvibe-mail --since '10 min ago' --no-pager
@@ -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
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:
the public door answers:
```sh
```sh
@@ -32,6 +32,7 @@ const { createEvents } = require('./events');
@@ -32,6 +32,7 @@ const { createEvents } = require('./events');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
+const ROOT = require('path').join(__dirname, '..');
/**
/**
* opts: config, store, stalwart, events, now (clock), fetchImpl (Network), log.
* opts: config, store, stalwart, events, now (clock), fetchImpl (Network), log.
@@ -81,6 +82,10 @@ async function createApp(opts = {}) {
@@ -81,6 +82,10 @@ async function createApp(opts = {}) {
const readiness = createServiceReadiness({ db: store.db, config, stalwart, version: VERSION });
const readiness = createServiceReadiness({ db: store.db, config, stalwart, version: VERSION });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // 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
// 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
// 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.
// Bearer token is judged by that token alone, so the cookie is not even read for it.
@@ -37,7 +37,7 @@ test('only the page and its files, the API, the machine answers and the sign-in
@@ -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))
.filter(([, body]) => /proxy_pass\s+http:\/\/127\.0\.0\.1:5250;/.test(body))
.map(([name]) => name)
.map(([name]) => name)
.sort();
.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.
// 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,
assert.strictEqual((CONF.match(/proxy_pass\s+/g) || []).length, proxied.length,
@@ -50,3 +50,15 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
@@ -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');
const missing = await s.get('/js/nope.js');
assert.strictEqual(missing.status, 404);
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');
+});