openvibers/openvibe.mail Commits
openvibe.email's page: a ten-minute address that shows its code, and your addresses (#3)
Changes
@@@ -7,7 +7,7 @@## 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)++## / the page, with /css/, /js/ and /shared/ for its files## /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,@@@ -81,11 +81,14 @@ server {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.++ # ── the page (server/http/pages.js) and its files ───────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>';++ limit_except GET HEAD { deny all; }++ proxy_pass http://127.0.0.1:5250;++ }++ location ~ ^/(css|js|shared)/ {++ limit_except GET HEAD { deny all; }++ proxy_pass http://127.0.0.1:5250;}# ── the machine answers ─────────────────────────────────nnew file mode 100644@@@ -0,0 +1,56 @@++/* OpenVibe.Email (Opus, 11-07). The house's dark surfaces from Shared's theme; one accent, the burner as the hero. */++/* The skip link: off screen until a keyboard reaches it. */++.skip { position: absolute; left: -9999px; top: 8px; z-index: 1000; padding: 8px 14px; border-radius: 10px; background: var(--accent-strong, #3472d8); color: #fff; font-weight: 700; }++.skip:focus { left: 12px; }++.ml-wrap { max-width: 880px; margin: 0 auto; padding: 32px max(16px, env(safe-area-inset-left, 0px)) 56px max(16px, env(safe-area-inset-right, 0px)); display: grid; gap: 28px; }++.ml-head { display: grid; gap: 10px; }++.ml-kicker { margin: 0; font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--accent, #3b82f6); }++.ml-head h1 { margin: 0; font-size: clamp(1.9rem, 5.5vw, 2.8rem); line-height: 1.08; letter-spacing: -.02em; text-wrap: balance; }++.ml-lead { margin: 0; max-width: 60ch; font-size: 1.05rem; line-height: 1.55; color: var(--text-secondary, #96a7c2); }++++/* The ten-minute address: one big panel. */++.ml-burner { position: relative; display: grid; gap: 16px; padding: 22px; border-radius: 22px; overflow: hidden;++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, #1f2d47));++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent), transparent 60%), var(--bg-card, #0f1729); }++.ml-burner h2 { margin: 0; font-size: 1.15rem; }++.ml-burner p { margin: 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++.ml-start { display: grid; gap: 12px; justify-items: start; }++.ml-start[hidden], .ml-box[hidden], .ml-code[hidden], .ml-wait[hidden], .ml-problem[hidden] { display: none; }++.ml-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; padding: 0 20px; border: 0; border-radius: 999px;++ background: var(--accent-strong, #3472d8); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 800; cursor: pointer; }++.ml-btn:disabled { opacity: .6; cursor: default; }++.ml-btn-quiet { min-height: 40px; padding: 0 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-primary, #e6edf7); font-weight: 700; }++.ml-box { display: grid; gap: 14px; }++.ml-addr-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }++.ml-addr { flex: 1 1 260px; min-width: 0; padding: 14px 16px; border-radius: 14px; background: var(--bg-input, #0d1424); border: 1px solid var(--border, #1f2d47);++ font: 600 clamp(.95rem, 3.6vw, 1.15rem)/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; color: var(--text-primary, #e6edf7); user-select: all; }++.ml-left { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.ml-wait { display: flex; align-items: center; gap: 10px; color: var(--text-secondary, #96a7c2); }++.ml-wait::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent, #3b82f6); }++.ml-code { display: grid; gap: 8px; padding: 16px; border-radius: 16px; background: color-mix(in srgb, #22c55e 12%, var(--bg-input, #0d1424)); border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); }++.ml-code-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }++.ml-code-text { font: 800 clamp(1.6rem, 8vw, 2.4rem)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; color: var(--text-primary, #e6edf7); }++.ml-code small { color: var(--text-secondary, #96a7c2); }++.ml-problem { color: var(--danger-text, #f26262); font-weight: 600; }++.ml-fine { font-size: .85rem; color: var(--text-muted, #7386a3); }++++/* What else it does: three plain cards, the house's even grid. */++.ml-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 12px; }++.ml-card { display: grid; gap: 6px; padding: 18px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }++.ml-card h3 { margin: 0; font-size: 1rem; }++.ml-card p { margin: 0; font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }++.ml-card .ml-soon { justify-self: start; padding: 2px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }++.ml-rules { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }++.ml-rules li { display: flex; gap: 10px; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++.ml-rules li::before { content: "✓"; color: #22c55e; font-weight: 800; }++/* Your addresses (signed in): one row each, the kind, the address, its state and how many messages came. */++.ml-mine[hidden] { display: none; }++.ml-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }++.ml-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }++.ml-row-addr { min-width: 0; font: 600 .95rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }++.ml-row-meta { grid-column: 1; font-size: .82rem; color: var(--text-secondary, #96a7c2); }++.ml-row-state { grid-column: 2; grid-row: 1 / span 2; justify-self: end; padding: 2px 10px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-secondary, #96a7c2); }++.ml-row-state.is-on { background: color-mix(in srgb, #22c55e 16%, transparent); color: #22c55e; }++.ml-h2 { margin: 0 0 10px; font-size: 1.05rem; }++.ml-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++@media (prefers-reduced-motion: reduce) { .ml-btn { transition: none; } }nnew file mode 100644@@@ -0,0 +1,134 @@++/* OpenVibe.Email's page (Opus, 11-07): a ten-minute address that shows its code, for anyone, and a signed-in person's++ * own addresses. Everything is real: the address is made by POST /api/v1/burners and its code read from++ * GET /api/v1/burners/:id/code with the burner's own token, kept in this browser until the address expires. Nothing++ * here sends mail, and no message body is ever shown: only the code, the sender and the time. */++(function () {++ 'use strict';++ var KEY = 'ov_mail_burner';++ var root = document.querySelector('[data-mail-burner]');++ if (!root) return;++ var start = root.querySelector('[data-mail-start]');++ var box = root.querySelector('[data-mail-box]');++ var addr = root.querySelector('[data-mail-address]');++ var copy = root.querySelector('[data-mail-copy]');++ var left = root.querySelector('[data-mail-left]');++ var wait = root.querySelector('[data-mail-wait]');++ var codeBox = root.querySelector('[data-mail-code]');++ var codeText = root.querySelector('[data-mail-code-text]');++ var codeFrom = root.querySelector('[data-mail-code-from]');++ var codeCopy = root.querySelector('[data-mail-code-copy]');++ var problem = root.querySelector('[data-mail-problem]');++ var poll = null, tick = null, current = null;++++ function save(b) { try { localStorage.setItem(KEY, JSON.stringify(b)); } catch (e) { /* private mode: this tab only */ } }++ function load() { try { var b = JSON.parse(localStorage.getItem(KEY) || 'null'); return b && Date.parse(b.expires_at) > Date.now() ? b : null; } catch (e) { return null; } }++ function forget() { try { localStorage.removeItem(KEY); } catch (e) { /* */ } }++ function say(text) { problem.textContent = text || ''; problem.hidden = !text; }++ function copyText(btn, text) {++ if (!navigator.clipboard) return;++ navigator.clipboard.writeText(text).then(function () {++ var was = btn.textContent; btn.textContent = 'Copied';++ setTimeout(function () { btn.textContent = was; }, 1400);++ }, function () {});++ }++ function stop() { clearInterval(poll); clearInterval(tick); poll = tick = null; }++++ function show(b) {++ current = b;++ start.hidden = true;++ box.hidden = false;++ addr.textContent = b.address;++ codeBox.hidden = true;++ wait.hidden = false;++ stop();++ var paint = function () {++ var ms = Date.parse(b.expires_at) - Date.now();++ if (ms <= 0) { expire(); return; }++ var m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000);++ left.textContent = m + ':' + (s < 10 ? '0' : '') + s + ' left';++ };++ paint();++ tick = setInterval(paint, 1000);++ var ask = function () {++ if (document.hidden || !current) return;++ fetch('/api/v1/burners/' + encodeURIComponent(b.id) + '/code', { headers: { Accept: 'application/json', 'x-mail-burner-token': b.token }, credentials: 'same-origin' })++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })++ .then(function (res) {++ if (res.ok && res.j && res.j.code) {++ codeText.textContent = res.j.code;++ codeFrom.textContent = (res.j.from ? 'From ' + res.j.from : 'Arrived') + (res.j.received_at ? ' at ' + new Date(res.j.received_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '');++ codeBox.hidden = false;++ wait.hidden = true;++ } else if (res.status === 404 && res.j && res.j.code !== 'mail.no_code') {++ expire();++ }++ })++ .catch(function () {});++ };++ ask();++ poll = setInterval(ask, 4000);++ }++ function expire() {++ stop();++ forget();++ current = null;++ box.hidden = true;++ start.hidden = false;++ start.querySelector('button').textContent = 'Get another 10-minute address';++ say('That address has expired, and its mail is deleted.');++ }++++ start.querySelector('button').addEventListener('click', function () {++ var btn = this;++ btn.disabled = true;++ say('');++ fetch('/api/v1/burners', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: '{}' })++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })++ .then(function (res) {++ btn.disabled = false;++ if (!res.ok || !res.j.burner || !res.j.token) {++ say(res.status === 429 ? 'You have made a lot of addresses from here this hour. Try again later, or sign in.'++ : res.status === 503 ? 'Every free address is in use right now. Try again in a few minutes.'++ : (res.j && res.j.detail) || 'No address was made. Try again.');++ return;++ }++ var b = { id: res.j.burner.id, address: res.j.burner.address, expires_at: res.j.burner.expires_at, token: res.j.token };++ save(b);++ show(b);++ })++ .catch(function () { btn.disabled = false; say('The network did not answer. Try again.'); });++ });++ copy.addEventListener('click', function () { if (current) copyText(copy, current.address); });++ codeCopy.addEventListener('click', function () { copyText(codeCopy, codeText.textContent); });++ var kept = load();++ if (kept) show(kept);++++ // A signed-in person's own addresses (GET /api/v1/me/mail): the kind, the address, its state and the count of++ // messages. A visitor gets 401 and the section stays hidden. Nothing here reads a message.++ var mine = document.querySelector('[data-mail-mine]');++ if (!mine) return;++ var list = mine.querySelector('[data-mail-mine-list]');++ var empty = mine.querySelector('[data-mail-mine-empty]');++ var KIND = { masks: 'Mask', burners: 'Ten-minute address', agent_inboxes: 'Agent inbox' };++ var STATE = { active: 'Active', disabled: 'Off', expired: 'Expired', consumed: 'Used' };++ fetch('/api/v1/me/mail', { credentials: 'same-origin', headers: { Accept: 'application/json' } })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (j) {++ if (!j) return;++ mine.hidden = false;++ var rows = [];++ ['agent_inboxes', 'burners', 'masks'].forEach(function (k) { (j[k] || []).forEach(function (x) { rows.push({ kind: k, x: x }); }); });++ empty.hidden = rows.length > 0;++ rows.forEach(function (r) {++ var li = document.createElement('li'); li.className = 'ml-row';++ var a = document.createElement('span'); a.className = 'ml-row-addr'; a.textContent = r.x.address || '';++ var st = document.createElement('span'); st.className = 'ml-row-state' + (r.x.state === 'active' ? ' is-on' : ''); st.textContent = STATE[r.x.state] || r.x.state || '';++ var meta = document.createElement('span'); meta.className = 'ml-row-meta';++ var n = r.x.counts && typeof r.x.counts.messages === 'number' ? r.x.counts.messages : 0;++ meta.textContent = KIND[r.kind] + (r.x.purpose ? ' · ' + r.x.purpose : '') + ' · ' + n + (n === 1 ? ' message' : ' messages');++ li.appendChild(a); li.appendChild(st); li.appendChild(meta);++ list.appendChild(li);++ });++ })++ .catch(function () {});++})();@@@ -114,6 +114,9 @@ async function createApp(opts = {}) {app.use('/api/v1', principal.middleware);app.use(createApi({ db: store.db, config, stalwart, events, principal, log }));++ // openvibe.email's page, its files and Shared's (server/http/pages.js).++ require('./http/pages').mountPages(app, { config });++app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {detail: 'No such API route.',ctx: req.ov,nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * openvibe.email's page (Opus, 11-07): a ten-minute address that shows its code, for anyone, and a signed-in person's++ * own addresses. It is drawn by openvibe-shared/shell (the navbar, the footer, the theme), complete without++ * JavaScript except for the parts that need the API, and it says only what M1 does today: it receives, shows a code,++ * and never shows a message body.++ *++ * GET / the page++ * GET /css/*, /js/* its own files, by content hash (immutable when the hash matches)++ * GET /shared/* openvibe-shared's files++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const express = require('express');++const shell = require('openvibe-shared/shell');++const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');++++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const SITE_NAME = 'OpenVibe.Email';++const NETWORK_URL = 'https://openvibe.network';++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++/** The page's own policy: its scripts, Shared's, Network's sign-in, and nothing framed. */++const CSP = [++ "default-src 'self'",++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com",++ "font-src 'self' data: https://fonts.gstatic.com",++ "img-src 'self' data: https://openvibe.network https://openvibe.media",++ "connect-src 'self' https://openvibe.network https://openvibe.chat https://openvibe.events https://cloudflareinsights.com",++ "frame-src 'self' https://openvibe.network",++ "frame-ancestors 'none'",++ "object-src 'none'",++ "base-uri 'self'",++ "form-action 'self' https://openvibe.network",++].join('; ');++++const BODY = fs.readFileSync(path.join(__dirname, '..', 'views', 'home.html'), 'utf8');++++function homePage(config) {++ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');++ const nav = {++ service: 'mail',++ apiBase: NETWORK_URL,++ links: [{ label: 'Home', href: '/', active: true }],++ history: { type: 'page', title: SITE_NAME },++ silentLogin: `${base}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ };++ const footer = { service: 'mail', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: `${SITE_NAME}: an address for every sign-up`,++ siteName: SITE_NAME,++ description: 'Ten-minute addresses that show you the code, and inboxes your agents can read. Receive only: nothing here sends mail.',++ canonical: `${base}/`,++ robots: 'index, follow',++ jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebApplication', name: SITE_NAME, url: `${base}/`, applicationCategory: 'CommunicationApplication', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } }],++ home: '/',++ navLinks: nav.links,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++<link rel="stylesheet" href="${ovServe.url('kit.css')}">++<link rel="stylesheet" href="${asset('css/mail.css')}">++<script src="${asset('js/mail.js')}" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++${BODY}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++/** Mount the page, its files and Shared's. Before the API's 404 handler. */++function mountPages(app, { config }) {++ app.use('/shared', ovServe.handler());++ app.use(['/css', '/js'], (req, res, next) => {++ const rel = `${req.baseUrl.slice(1)}${req.path}`;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!req.query.v && req.query.v === assetVersion(rel) }));++ next();++ });++ app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));++ app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false }));++ app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));++ let html = null;++ app.get('/', (req, res) => {++ html = html || homePage(config);++ res.set('Content-Security-Policy', CSP);++ res.set('Cache-Control', cache.htmlHeaders({ private: false }));++ res.type('html').send(html);++ });++}++++module.exports = { mountPages, homePage, CSP, asset };nnew file mode 100644@@@ -0,0 +1,50 @@++<main class="ml-wrap" id="main">++<header class="ml-head">++<p class="ml-kicker">OpenVibe.Email</p>++<h1>An address for every sign-up</h1>++<p class="ml-lead">Throwaway addresses that show you the code, and inboxes your agents can read. It only receives: nothing here sends mail.</p>++</header>++++<section class="ml-burner" data-mail-burner aria-labelledby="ml-burner-h">++<h2 id="ml-burner-h">A ten-minute address</h2>++<div class="ml-start" data-mail-start>++<p>Use it where a site wants an email before it lets you in. The code it sends appears right here.</p>++<button class="ml-btn" type="button">Get a 10-minute address</button>++</div>++<div class="ml-box" data-mail-box hidden>++<div class="ml-addr-row"><span class="ml-addr" data-mail-address></span><button class="ml-btn ml-btn-quiet" type="button" data-mail-copy>Copy</button></div>++<p><span class="ml-left" data-mail-left></span> · paste it into the site's form.</p>++<p class="ml-wait" data-mail-wait role="status">Waiting for a code…</p>++<div class="ml-code" data-mail-code hidden role="status">++<div class="ml-code-row"><span class="ml-code-text" data-mail-code-text></span><button class="ml-btn ml-btn-quiet" type="button" data-mail-code-copy>Copy the code</button></div>++<small data-mail-code-from></small>++</div>++</div>++<p class="ml-problem" data-mail-problem role="alert" hidden></p>++<p class="ml-fine">No account needed. The address and its mail are deleted when the ten minutes end. Only the code, the sender and the time are shown; the rest of the message is never displayed.</p>++</section>++++<section class="ml-mine" data-mail-mine aria-labelledby="ml-mine-h" hidden>++<h2 id="ml-mine-h" class="ml-h2">Your addresses</h2>++<p class="ml-fine" data-mail-mine-empty hidden>None yet. A ten-minute address made while you are signed in, or an inbox you give an agent, shows here.</p>++<ul class="ml-list" data-mail-mine-list></ul>++</section>++++<section aria-labelledby="ml-more-h">++<h2 id="ml-more-h" class="ml-sr">What else it does</h2>++<div class="ml-grid">++<article class="ml-card"><h3>Inboxes for your agents</h3><p>Give an agent its own address for sign-ups and codes, so it never needs yours. It reads only what arrives there.</p></article>++<article class="ml-card"><h3>Masks</h3><span class="ml-soon">Later</span><p>An address per site that forwards to you, so a leak or a spammer never learns your real one. One switch turns it off.</p></article>++<article class="ml-card"><h3>Your own inbox</h3><span class="ml-soon">Later</span><p>Real mailboxes, sending and your own domain come after this. Today it only receives.</p></article>++</div>++</section>++++<section aria-labelledby="ml-rules-h">++<h2 id="ml-rules-h" class="ml-h2">How it treats mail</h2>++<ul class="ml-rules">++<li>Receive only: there is no way to send from these addresses.</li>++<li>A message body is read only to find a code, and it is never stored outside the mailbox or shown here.</li>++<li>Expired addresses bounce, and their mail is deleted.</li>++</ul>++</section>++</main>@@@ -81,6 +81,7 @@ async function boot(opts = {}) {}return {++ base,network,mail,store,@@@ -3,7 +3,7 @@/*** 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.++ * machine answers). `/` is the page, and its files come from /css/, /js/ and /shared/.*/const test = require('node:test');const assert = require('node:assert');@@@ -16,7 +16,7 @@ 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)) {++ for (const match of CONF.matchAll(/location\s+(?:(?:=|~\*?)\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {out.set(match[1], match[2]);}return out;@@@ -31,25 +31,26 @@ test('openvibe.email has the three house server_name lines and the existing certassert.match(CONF, /include snippets\/security-txt\.conf;/);});--test('only the API, the machine answers and the sign-in routes are proxied', () => {++test('only the page and its files, 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());++ assert.deepStrictEqual(proxied, ['/', '^/(css|js|shared)/', '/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');++ 'every proxy_pass belongs to one of the seven locations');});--test('/ is the placeholder and unknown paths are not proxied', () => {++test('/ is the page (GET and HEAD only) 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(root, /proxy_pass http:\/\/127\.0\.0\.1:5250;/);++ assert.match(root, /limit_except GET HEAD \{ deny all; \}/);++ assert.match(blocks.get('^/(css|js|shared)/'), /limit_except GET HEAD \{ deny all; \}/);assert.match(blocks.get('/api/'), /return 404;/);assert.doesNotMatch(blocks.get('/api/'), /proxy_pass/);assert.match(CONF, /location ~\* \^\/\(metrics\|internal\)\(/);nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * openvibe.email's page (Opus, 11-07): / is the page with the ten-minute address, its files are served by content++ * hash, Shared's files come from /shared, and the page has its own strict policy.++ * node --test test/page.test.js++ */++const test = require('node:test');++const assert = require('node:assert');++const { boot } = require('./helpers/boot');++const { asset } = require('../server/http/pages');++++test('/ is the page: the ten-minute address, the honest cards, the navbar, a strict policy', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const r = await s.get('/');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /text\/html/);++ for (const marker of ['data-mail-burner', 'Get a 10-minute address', 'data-mail-mine', 'Inboxes for your agents', 'navbar-mount', '/shared/']) {++ assert.ok(r.text.includes(marker), marker);++ }++ // Masks and own inboxes are not offered as if they worked today: each card says Later.++ assert.strictEqual((r.text.match(/class="ml-soon">Later</g) || []).length, 2);++ assert.doesNotMatch(r.text, /<form[^>]*action="\/api/, 'nothing posts to the API without the script');++ const csp = r.headers.get('content-security-policy') || '';++ assert.match(csp, /frame-ancestors 'none'/);++ assert.match(csp, /default-src 'self'/);++ assert.match(csp, /form-action 'self' https:\/\/openvibe\.network/);++});++++test('the page\'s files are served by content hash, and Shared\'s from /shared', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const css = await s.get(asset('css/mail.css'));++ assert.strictEqual(css.status, 200);++ assert.match(css.headers.get('cache-control') || '', /immutable/);++ const js = await s.get(asset('js/mail.js'));++ assert.strictEqual(js.status, 200);++ assert.match(js.text, /\/api\/v1\/burners/);++ const stale = await s.get('/js/mail.js?v=0000000000');++ assert.strictEqual(stale.status, 200);++ assert.doesNotMatch(stale.headers.get('cache-control') || '', /immutable/, 'a wrong hash is not cached for a year');++ const navbar = await s.get('/shared/navbar.js');++ assert.strictEqual(navbar.status, 200);++ const missing = await s.get('/js/nope.js');++ assert.strictEqual(missing.status, 404);++});
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
## The Mail Node service listens on 127.0.0.1:5250 and serves only JSON. This vhost terminates TLS and is the
## The Mail Node service listens on 127.0.0.1:5250 and serves only JSON. This vhost terminates TLS and is the
## public door:
## public door:
##
##
-## / a placeholder until the openvibe.email page lands (not proxied)
+## / the page, with /css/, /js/ and /shared/ for its files
## /api/v1/ the API; the app enforces Network auth, CORS and the write rules
## /api/v1/ the API; the app enforces Network auth, CORS and the write rules
## /api/health, /api/ready the machine answers
## /api/health, /api/ready the machine answers
## /auth/ sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback,
## /auth/ sign-in with OpenVibe.Network (openvibe-sdk/sso): /auth/login, /auth/callback,
@@ -81,11 +81,14 @@ server {
@@ -81,11 +81,14 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
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.
+ # ── the page (server/http/pages.js) and its files ───────
location = / {
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>';
+ limit_except GET HEAD { deny all; }
+ proxy_pass http://127.0.0.1:5250;
+ }
+ location ~ ^/(css|js|shared)/ {
+ limit_except GET HEAD { deny all; }
+ proxy_pass http://127.0.0.1:5250;
}
}
# ── the machine answers ─────────────────────────────────
# ── the machine answers ─────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+/* OpenVibe.Email (Opus, 11-07). The house's dark surfaces from Shared's theme; one accent, the burner as the hero. */
+/* The skip link: off screen until a keyboard reaches it. */
+.skip { position: absolute; left: -9999px; top: 8px; z-index: 1000; padding: 8px 14px; border-radius: 10px; background: var(--accent-strong, #3472d8); color: #fff; font-weight: 700; }
+.skip:focus { left: 12px; }
+.ml-wrap { max-width: 880px; margin: 0 auto; padding: 32px max(16px, env(safe-area-inset-left, 0px)) 56px max(16px, env(safe-area-inset-right, 0px)); display: grid; gap: 28px; }
+.ml-head { display: grid; gap: 10px; }
+.ml-kicker { margin: 0; font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--accent, #3b82f6); }
+.ml-head h1 { margin: 0; font-size: clamp(1.9rem, 5.5vw, 2.8rem); line-height: 1.08; letter-spacing: -.02em; text-wrap: balance; }
+.ml-lead { margin: 0; max-width: 60ch; font-size: 1.05rem; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
+
+/* The ten-minute address: one big panel. */
+.ml-burner { position: relative; display: grid; gap: 16px; padding: 22px; border-radius: 22px; overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, #1f2d47));
+ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent), transparent 60%), var(--bg-card, #0f1729); }
+.ml-burner h2 { margin: 0; font-size: 1.15rem; }
+.ml-burner p { margin: 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+.ml-start { display: grid; gap: 12px; justify-items: start; }
+.ml-start[hidden], .ml-box[hidden], .ml-code[hidden], .ml-wait[hidden], .ml-problem[hidden] { display: none; }
+.ml-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; padding: 0 20px; border: 0; border-radius: 999px;
+ background: var(--accent-strong, #3472d8); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 800; cursor: pointer; }
+.ml-btn:disabled { opacity: .6; cursor: default; }
+.ml-btn-quiet { min-height: 40px; padding: 0 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-primary, #e6edf7); font-weight: 700; }
+.ml-box { display: grid; gap: 14px; }
+.ml-addr-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
+.ml-addr { flex: 1 1 260px; min-width: 0; padding: 14px 16px; border-radius: 14px; background: var(--bg-input, #0d1424); border: 1px solid var(--border, #1f2d47);
+ font: 600 clamp(.95rem, 3.6vw, 1.15rem)/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; color: var(--text-primary, #e6edf7); user-select: all; }
+.ml-left { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.ml-wait { display: flex; align-items: center; gap: 10px; color: var(--text-secondary, #96a7c2); }
+.ml-wait::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent, #3b82f6); }
+.ml-code { display: grid; gap: 8px; padding: 16px; border-radius: 16px; background: color-mix(in srgb, #22c55e 12%, var(--bg-input, #0d1424)); border: 1px solid color-mix(in srgb, #22c55e 40%, transparent); }
+.ml-code-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
+.ml-code-text { font: 800 clamp(1.6rem, 8vw, 2.4rem)/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; color: var(--text-primary, #e6edf7); }
+.ml-code small { color: var(--text-secondary, #96a7c2); }
+.ml-problem { color: var(--danger-text, #f26262); font-weight: 600; }
+.ml-fine { font-size: .85rem; color: var(--text-muted, #7386a3); }
+
+/* What else it does: three plain cards, the house's even grid. */
+.ml-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 12px; }
+.ml-card { display: grid; gap: 6px; padding: 18px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
+.ml-card h3 { margin: 0; font-size: 1rem; }
+.ml-card p { margin: 0; font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
+.ml-card .ml-soon { justify-self: start; padding: 2px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
+.ml-rules { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
+.ml-rules li { display: flex; gap: 10px; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+.ml-rules li::before { content: "✓"; color: #22c55e; font-weight: 800; }
+/* Your addresses (signed in): one row each, the kind, the address, its state and how many messages came. */
+.ml-mine[hidden] { display: none; }
+.ml-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
+.ml-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
+.ml-row-addr { min-width: 0; font: 600 .95rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
+.ml-row-meta { grid-column: 1; font-size: .82rem; color: var(--text-secondary, #96a7c2); }
+.ml-row-state { grid-column: 2; grid-row: 1 / span 2; justify-self: end; padding: 2px 10px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-secondary, #96a7c2); }
+.ml-row-state.is-on { background: color-mix(in srgb, #22c55e 16%, transparent); color: #22c55e; }
+.ml-h2 { margin: 0 0 10px; font-size: 1.05rem; }
+.ml-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+@media (prefers-reduced-motion: reduce) { .ml-btn { transition: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+/* OpenVibe.Email's page (Opus, 11-07): a ten-minute address that shows its code, for anyone, and a signed-in person's
+ * own addresses. Everything is real: the address is made by POST /api/v1/burners and its code read from
+ * GET /api/v1/burners/:id/code with the burner's own token, kept in this browser until the address expires. Nothing
+ * here sends mail, and no message body is ever shown: only the code, the sender and the time. */
+(function () {
+ 'use strict';
+ var KEY = 'ov_mail_burner';
+ var root = document.querySelector('[data-mail-burner]');
+ if (!root) return;
+ var start = root.querySelector('[data-mail-start]');
+ var box = root.querySelector('[data-mail-box]');
+ var addr = root.querySelector('[data-mail-address]');
+ var copy = root.querySelector('[data-mail-copy]');
+ var left = root.querySelector('[data-mail-left]');
+ var wait = root.querySelector('[data-mail-wait]');
+ var codeBox = root.querySelector('[data-mail-code]');
+ var codeText = root.querySelector('[data-mail-code-text]');
+ var codeFrom = root.querySelector('[data-mail-code-from]');
+ var codeCopy = root.querySelector('[data-mail-code-copy]');
+ var problem = root.querySelector('[data-mail-problem]');
+ var poll = null, tick = null, current = null;
+
+ function save(b) { try { localStorage.setItem(KEY, JSON.stringify(b)); } catch (e) { /* private mode: this tab only */ } }
+ function load() { try { var b = JSON.parse(localStorage.getItem(KEY) || 'null'); return b && Date.parse(b.expires_at) > Date.now() ? b : null; } catch (e) { return null; } }
+ function forget() { try { localStorage.removeItem(KEY); } catch (e) { /* */ } }
+ function say(text) { problem.textContent = text || ''; problem.hidden = !text; }
+ function copyText(btn, text) {
+ if (!navigator.clipboard) return;
+ navigator.clipboard.writeText(text).then(function () {
+ var was = btn.textContent; btn.textContent = 'Copied';
+ setTimeout(function () { btn.textContent = was; }, 1400);
+ }, function () {});
+ }
+ function stop() { clearInterval(poll); clearInterval(tick); poll = tick = null; }
+
+ function show(b) {
+ current = b;
+ start.hidden = true;
+ box.hidden = false;
+ addr.textContent = b.address;
+ codeBox.hidden = true;
+ wait.hidden = false;
+ stop();
+ var paint = function () {
+ var ms = Date.parse(b.expires_at) - Date.now();
+ if (ms <= 0) { expire(); return; }
+ var m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000);
+ left.textContent = m + ':' + (s < 10 ? '0' : '') + s + ' left';
+ };
+ paint();
+ tick = setInterval(paint, 1000);
+ var ask = function () {
+ if (document.hidden || !current) return;
+ fetch('/api/v1/burners/' + encodeURIComponent(b.id) + '/code', { headers: { Accept: 'application/json', 'x-mail-burner-token': b.token }, credentials: 'same-origin' })
+ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
+ .then(function (res) {
+ if (res.ok && res.j && res.j.code) {
+ codeText.textContent = res.j.code;
+ codeFrom.textContent = (res.j.from ? 'From ' + res.j.from : 'Arrived') + (res.j.received_at ? ' at ' + new Date(res.j.received_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '');
+ codeBox.hidden = false;
+ wait.hidden = true;
+ } else if (res.status === 404 && res.j && res.j.code !== 'mail.no_code') {
+ expire();
+ }
+ })
+ .catch(function () {});
+ };
+ ask();
+ poll = setInterval(ask, 4000);
+ }
+ function expire() {
+ stop();
+ forget();
+ current = null;
+ box.hidden = true;
+ start.hidden = false;
+ start.querySelector('button').textContent = 'Get another 10-minute address';
+ say('That address has expired, and its mail is deleted.');
+ }
+
+ start.querySelector('button').addEventListener('click', function () {
+ var btn = this;
+ btn.disabled = true;
+ say('');
+ fetch('/api/v1/burners', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: '{}' })
+ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
+ .then(function (res) {
+ btn.disabled = false;
+ if (!res.ok || !res.j.burner || !res.j.token) {
+ say(res.status === 429 ? 'You have made a lot of addresses from here this hour. Try again later, or sign in.'
+ : res.status === 503 ? 'Every free address is in use right now. Try again in a few minutes.'
+ : (res.j && res.j.detail) || 'No address was made. Try again.');
+ return;
+ }
+ var b = { id: res.j.burner.id, address: res.j.burner.address, expires_at: res.j.burner.expires_at, token: res.j.token };
+ save(b);
+ show(b);
+ })
+ .catch(function () { btn.disabled = false; say('The network did not answer. Try again.'); });
+ });
+ copy.addEventListener('click', function () { if (current) copyText(copy, current.address); });
+ codeCopy.addEventListener('click', function () { copyText(codeCopy, codeText.textContent); });
+ var kept = load();
+ if (kept) show(kept);
+
+ // A signed-in person's own addresses (GET /api/v1/me/mail): the kind, the address, its state and the count of
+ // messages. A visitor gets 401 and the section stays hidden. Nothing here reads a message.
+ var mine = document.querySelector('[data-mail-mine]');
+ if (!mine) return;
+ var list = mine.querySelector('[data-mail-mine-list]');
+ var empty = mine.querySelector('[data-mail-mine-empty]');
+ var KIND = { masks: 'Mask', burners: 'Ten-minute address', agent_inboxes: 'Agent inbox' };
+ var STATE = { active: 'Active', disabled: 'Off', expired: 'Expired', consumed: 'Used' };
+ fetch('/api/v1/me/mail', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (j) {
+ if (!j) return;
+ mine.hidden = false;
+ var rows = [];
+ ['agent_inboxes', 'burners', 'masks'].forEach(function (k) { (j[k] || []).forEach(function (x) { rows.push({ kind: k, x: x }); }); });
+ empty.hidden = rows.length > 0;
+ rows.forEach(function (r) {
+ var li = document.createElement('li'); li.className = 'ml-row';
+ var a = document.createElement('span'); a.className = 'ml-row-addr'; a.textContent = r.x.address || '';
+ var st = document.createElement('span'); st.className = 'ml-row-state' + (r.x.state === 'active' ? ' is-on' : ''); st.textContent = STATE[r.x.state] || r.x.state || '';
+ var meta = document.createElement('span'); meta.className = 'ml-row-meta';
+ var n = r.x.counts && typeof r.x.counts.messages === 'number' ? r.x.counts.messages : 0;
+ meta.textContent = KIND[r.kind] + (r.x.purpose ? ' · ' + r.x.purpose : '') + ' · ' + n + (n === 1 ? ' message' : ' messages');
+ li.appendChild(a); li.appendChild(st); li.appendChild(meta);
+ list.appendChild(li);
+ });
+ })
+ .catch(function () {});
+})();
@@ -114,6 +114,9 @@ async function createApp(opts = {}) {
@@ -114,6 +114,9 @@ async function createApp(opts = {}) {
app.use('/api/v1', principal.middleware);
app.use('/api/v1', principal.middleware);
app.use(createApi({ db: store.db, config, stalwart, events, principal, log }));
app.use(createApi({ db: store.db, config, stalwart, events, principal, log }));
+ // openvibe.email's page, its files and Shared's (server/http/pages.js).
+ require('./http/pages').mountPages(app, { config });
+
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
detail: 'No such API route.',
detail: 'No such API route.',
ctx: req.ov,
ctx: req.ov,
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * openvibe.email's page (Opus, 11-07): a ten-minute address that shows its code, for anyone, and a signed-in person's
+ * own addresses. It is drawn by openvibe-shared/shell (the navbar, the footer, the theme), complete without
+ * JavaScript except for the parts that need the API, and it says only what M1 does today: it receives, shows a code,
+ * and never shows a message body.
+ *
+ * GET / the page
+ * GET /css/*, /js/* its own files, by content hash (immutable when the hash matches)
+ * GET /shared/* openvibe-shared's files
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const shell = require('openvibe-shared/shell');
+const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
+
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const SITE_NAME = 'OpenVibe.Email';
+const NETWORK_URL = 'https://openvibe.network';
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+/** The page's own policy: its scripts, Shared's, Network's sign-in, and nothing framed. */
+const CSP = [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",
+ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com",
+ "font-src 'self' data: https://fonts.gstatic.com",
+ "img-src 'self' data: https://openvibe.network https://openvibe.media",
+ "connect-src 'self' https://openvibe.network https://openvibe.chat https://openvibe.events https://cloudflareinsights.com",
+ "frame-src 'self' https://openvibe.network",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self' https://openvibe.network",
+].join('; ');
+
+const BODY = fs.readFileSync(path.join(__dirname, '..', 'views', 'home.html'), 'utf8');
+
+function homePage(config) {
+ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
+ const nav = {
+ service: 'mail',
+ apiBase: NETWORK_URL,
+ links: [{ label: 'Home', href: '/', active: true }],
+ history: { type: 'page', title: SITE_NAME },
+ silentLogin: `${base}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ };
+ const footer = { service: 'mail', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: `${SITE_NAME}: an address for every sign-up`,
+ siteName: SITE_NAME,
+ description: 'Ten-minute addresses that show you the code, and inboxes your agents can read. Receive only: nothing here sends mail.',
+ canonical: `${base}/`,
+ robots: 'index, follow',
+ jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebApplication', name: SITE_NAME, url: `${base}/`, applicationCategory: 'CommunicationApplication', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } }],
+ home: '/',
+ navLinks: nav.links,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+<link rel="stylesheet" href="${ovServe.url('kit.css')}">
+<link rel="stylesheet" href="${asset('css/mail.css')}">
+<script src="${asset('js/mail.js')}" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+${BODY}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+/** Mount the page, its files and Shared's. Before the API's 404 handler. */
+function mountPages(app, { config }) {
+ app.use('/shared', ovServe.handler());
+ app.use(['/css', '/js'], (req, res, next) => {
+ const rel = `${req.baseUrl.slice(1)}${req.path}`;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!req.query.v && req.query.v === assetVersion(rel) }));
+ next();
+ });
+ app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));
+ app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false }));
+ app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));
+ let html = null;
+ app.get('/', (req, res) => {
+ html = html || homePage(config);
+ res.set('Content-Security-Policy', CSP);
+ res.set('Cache-Control', cache.htmlHeaders({ private: false }));
+ res.type('html').send(html);
+ });
+}
+
+module.exports = { mountPages, homePage, CSP, asset };
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+<main class="ml-wrap" id="main">
+<header class="ml-head">
+<p class="ml-kicker">OpenVibe.Email</p>
+<h1>An address for every sign-up</h1>
+<p class="ml-lead">Throwaway addresses that show you the code, and inboxes your agents can read. It only receives: nothing here sends mail.</p>
+</header>
+
+<section class="ml-burner" data-mail-burner aria-labelledby="ml-burner-h">
+<h2 id="ml-burner-h">A ten-minute address</h2>
+<div class="ml-start" data-mail-start>
+<p>Use it where a site wants an email before it lets you in. The code it sends appears right here.</p>
+<button class="ml-btn" type="button">Get a 10-minute address</button>
+</div>
+<div class="ml-box" data-mail-box hidden>
+<div class="ml-addr-row"><span class="ml-addr" data-mail-address></span><button class="ml-btn ml-btn-quiet" type="button" data-mail-copy>Copy</button></div>
+<p><span class="ml-left" data-mail-left></span> · paste it into the site's form.</p>
+<p class="ml-wait" data-mail-wait role="status">Waiting for a code…</p>
+<div class="ml-code" data-mail-code hidden role="status">
+<div class="ml-code-row"><span class="ml-code-text" data-mail-code-text></span><button class="ml-btn ml-btn-quiet" type="button" data-mail-code-copy>Copy the code</button></div>
+<small data-mail-code-from></small>
+</div>
+</div>
+<p class="ml-problem" data-mail-problem role="alert" hidden></p>
+<p class="ml-fine">No account needed. The address and its mail are deleted when the ten minutes end. Only the code, the sender and the time are shown; the rest of the message is never displayed.</p>
+</section>
+
+<section class="ml-mine" data-mail-mine aria-labelledby="ml-mine-h" hidden>
+<h2 id="ml-mine-h" class="ml-h2">Your addresses</h2>
+<p class="ml-fine" data-mail-mine-empty hidden>None yet. A ten-minute address made while you are signed in, or an inbox you give an agent, shows here.</p>
+<ul class="ml-list" data-mail-mine-list></ul>
+</section>
+
+<section aria-labelledby="ml-more-h">
+<h2 id="ml-more-h" class="ml-sr">What else it does</h2>
+<div class="ml-grid">
+<article class="ml-card"><h3>Inboxes for your agents</h3><p>Give an agent its own address for sign-ups and codes, so it never needs yours. It reads only what arrives there.</p></article>
+<article class="ml-card"><h3>Masks</h3><span class="ml-soon">Later</span><p>An address per site that forwards to you, so a leak or a spammer never learns your real one. One switch turns it off.</p></article>
+<article class="ml-card"><h3>Your own inbox</h3><span class="ml-soon">Later</span><p>Real mailboxes, sending and your own domain come after this. Today it only receives.</p></article>
+</div>
+</section>
+
+<section aria-labelledby="ml-rules-h">
+<h2 id="ml-rules-h" class="ml-h2">How it treats mail</h2>
+<ul class="ml-rules">
+<li>Receive only: there is no way to send from these addresses.</li>
+<li>A message body is read only to find a code, and it is never stored outside the mailbox or shown here.</li>
+<li>Expired addresses bounce, and their mail is deleted.</li>
+</ul>
+</section>
+</main>
@@ -81,6 +81,7 @@ async function boot(opts = {}) {
@@ -81,6 +81,7 @@ async function boot(opts = {}) {
}
}
return {
return {
+ base,
network,
network,
mail,
mail,
store,
store,
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* deploy/nginx/openvibe.email.conf is the public door. This test is the static guard: the three server_name lines,
* 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
* 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.
+ * machine answers). `/` is the page, and its files come from /css/, /js/ and /shared/.
*/
*/
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert');
const assert = require('node:assert');
@@ -16,7 +16,7 @@ const CONF = fs.readFileSync(FILE, 'utf8');
@@ -16,7 +16,7 @@ const CONF = fs.readFileSync(FILE, 'utf8');
/** location → block body (the file has no nested braces inside a location). */
/** location → block body (the file has no nested braces inside a location). */
function locations() {
function locations() {
const out = new Map();
const out = new Map();
- for (const match of CONF.matchAll(/location\s+(?:=\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {
+ for (const match of CONF.matchAll(/location\s+(?:(?:=|~\*?)\s+)?([^\s{]+)\s*\{([\s\S]*?)\n\s*\}/g)) {
out.set(match[1], match[2]);
out.set(match[1], match[2]);
}
}
return out;
return out;
@@ -31,25 +31,26 @@ test('openvibe.email has the three house server_name lines and the existing cert
@@ -31,25 +31,26 @@ test('openvibe.email has the three house server_name lines and the existing cert
assert.match(CONF, /include snippets\/security-txt\.conf;/);
assert.match(CONF, /include snippets\/security-txt\.conf;/);
});
});
-test('only the API, the machine answers and the sign-in routes are proxied', () => {
+test('only the page and its files, the API, the machine answers and the sign-in routes are proxied', () => {
const blocks = locations();
const blocks = locations();
const proxied = [...blocks.entries()]
const proxied = [...blocks.entries()]
.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, ['/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'].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,
- 'every proxy_pass belongs to one of the five locations');
+ 'every proxy_pass belongs to one of the seven locations');
});
});
-test('/ is the placeholder and unknown paths are not proxied', () => {
+test('/ is the page (GET and HEAD only) and unknown paths are not proxied', () => {
const blocks = locations();
const blocks = locations();
const root = blocks.get('/');
const root = blocks.get('/');
assert.ok(root, 'there is an exact / location');
assert.ok(root, 'there is an exact / location');
- assert.match(root, /return 200 /);
- assert.doesNotMatch(root, /proxy_pass/);
+ assert.match(root, /proxy_pass http:\/\/127\.0\.0\.1:5250;/);
+ assert.match(root, /limit_except GET HEAD \{ deny all; \}/);
+ assert.match(blocks.get('^/(css|js|shared)/'), /limit_except GET HEAD \{ deny all; \}/);
assert.match(blocks.get('/api/'), /return 404;/);
assert.match(blocks.get('/api/'), /return 404;/);
assert.doesNotMatch(blocks.get('/api/'), /proxy_pass/);
assert.doesNotMatch(blocks.get('/api/'), /proxy_pass/);
assert.match(CONF, /location ~\* \^\/\(metrics\|internal\)\(/);
assert.match(CONF, /location ~\* \^\/\(metrics\|internal\)\(/);
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * openvibe.email's page (Opus, 11-07): / is the page with the ten-minute address, its files are served by content
+ * hash, Shared's files come from /shared, and the page has its own strict policy.
+ * node --test test/page.test.js
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const { boot } = require('./helpers/boot');
+const { asset } = require('../server/http/pages');
+
+test('/ is the page: the ten-minute address, the honest cards, the navbar, a strict policy', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const r = await s.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /text\/html/);
+ for (const marker of ['data-mail-burner', 'Get a 10-minute address', 'data-mail-mine', 'Inboxes for your agents', 'navbar-mount', '/shared/']) {
+ assert.ok(r.text.includes(marker), marker);
+ }
+ // Masks and own inboxes are not offered as if they worked today: each card says Later.
+ assert.strictEqual((r.text.match(/class="ml-soon">Later</g) || []).length, 2);
+ assert.doesNotMatch(r.text, /<form[^>]*action="\/api/, 'nothing posts to the API without the script');
+ const csp = r.headers.get('content-security-policy') || '';
+ assert.match(csp, /frame-ancestors 'none'/);
+ assert.match(csp, /default-src 'self'/);
+ assert.match(csp, /form-action 'self' https:\/\/openvibe\.network/);
+});
+
+test('the page\'s files are served by content hash, and Shared\'s from /shared', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const css = await s.get(asset('css/mail.css'));
+ assert.strictEqual(css.status, 200);
+ assert.match(css.headers.get('cache-control') || '', /immutable/);
+ const js = await s.get(asset('js/mail.js'));
+ assert.strictEqual(js.status, 200);
+ assert.match(js.text, /\/api\/v1\/burners/);
+ const stale = await s.get('/js/mail.js?v=0000000000');
+ assert.strictEqual(stale.status, 200);
+ assert.doesNotMatch(stale.headers.get('cache-control') || '', /immutable/, 'a wrong hash is not cached for a year');
+ const navbar = await s.get('/shared/navbar.js');
+ assert.strictEqual(navbar.status, 200);
+ const missing = await s.get('/js/nope.js');
+ assert.strictEqual(missing.status, 404);
+});