openvibers/openvibe.mail Commits
Mail M1.5: read your mail on openvibe.email (#10)
Changes
@@@ -6,6 +6,28 @@ M1 is **receive only**: Stalwart Community listens on port 25, the service handsOpenVibe mailboxes, and nothing here sends or relays mail. Sending, inbox AI and paid custom domains are M2–M4 inthe OpenVibe Seven Domains plan (2026-11-07) §8.++## M1.5: read your mail (2026-11-07)++++`/inbox` on openvibe.email shows a signed-in person's mailbox and the addresses that deliver into it (masks, agent++inboxes), for its owner only (another person's message is the same 404 as a missing one):++++| Route | What |++|---|---|++| `GET /api/v1/me/mail/addresses` | the mailbox and the addresses that deliver into it |++| `GET /api/v1/me/mail/messages?address=&cursor=` | a page, newest first; an address is matched on the server's `Delivered-To` stamp only |++| `GET /api/v1/me/mail/messages/:id[?images=1]` | one message: text, and HTML cleaned on the server |++| `GET /api/v1/me/mail/messages/:id/attachments/:part` | a download (`attachment`, `nosniff`, an allow-listed type or `application/octet-stream`) |++| `POST …/:id/read`, `…/:id/unread`, `DELETE …/:id` | seen flags; Delete moves it to the Trash |++++**Two layers for a sender's HTML.** `server/domain/reading.js` cleans it with++[sanitize-html](https://github.com/apostrophecms/sanitize-html) (MIT): an allow-list of tags and attributes, `style`++limited to layout and colour properties whose values can hold no `url()`, `expression()` or `@import`, links only++http(s)/mailto opening in a new tab with `rel="noopener noreferrer"`. Remote images, the way senders track opening,++are replaced by their alt text and counted (`blocked_images`) until the person asks (`?images=1`, https only). The++page (`public/js/inbox.js`) then shows the result in an iframe with no `allow-scripts` and its own CSP++(`default-src 'none'`). Bodies go to their owner and nowhere else: never a log line, an event or a row++(`test/reading.test.js` and the Stalwart acceptance test check the logger).++## What is in M1| Piece | What it does |@@@ -90,6 +90,11 @@ server {limit_except GET HEAD { deny all; }proxy_pass http://127.0.0.1:5250;}++ # The inbox (M1.5): the page shell for a signed-in person; the mail itself comes from /api/v1/me/mail/*.++ location ~ ^/inbox(/[A-Za-z0-9_-]+)?$ {++ limit_except GET HEAD { deny all; }++ proxy_pass http://127.0.0.1:5250;++ }# ── the machine answers ─────────────────────────────────location = /api/health {@@@ -16,7 +16,8 @@"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.142.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "sanitize-html": "^2.18.1"},"devDependencies": {"@electric-sql/pglite": "^0.5.8"@@@ -243,6 +244,12 @@"node": ">=20.0.0"}},++ "node_modules/dayjs": {++ "version": "1.11.23",++ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",++ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",++ "license": "MIT"++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -440,6 +447,18 @@"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==","license": "MIT"},++ "node_modules/escape-string-regexp": {++ "version": "4.0.0",++ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",++ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },"node_modules/etag": {"version": "1.8.1","resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",@@@ -813,6 +832,15 @@"node": ">= 0.10"}},++ "node_modules/is-plain-object": {++ "version": "5.1.0",++ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",++ "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/json-schema-traverse": {"version": "1.0.0","resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",@@@ -868,6 +896,15 @@"safe-buffer": "^5.0.1"}},++ "node_modules/launder": {++ "version": "1.7.2",++ "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",++ "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",++ "license": "MIT",++ "dependencies": {++ "dayjs": "^1.11.23"++ }++ },"node_modules/linkedom": {"version": "0.18.15","resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",@@@ -1009,6 +1046,24 @@"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==","license": "MIT"},++ "node_modules/nanoid": {++ "version": "3.3.20",++ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",++ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/ai"++ }++ ],++ "license": "MIT",++ "bin": {++ "nanoid": "bin/nanoid.cjs"++ },++ "engines": {++ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"++ }++ },"node_modules/negotiator": {"version": "0.6.3","resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",@@@ -1119,6 +1174,12 @@"openvibe-build-assets": "scripts/build-assets.js"}},++ "node_modules/parse-srcset": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",++ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",++ "license": "MIT"++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -1223,6 +1284,40 @@"split2": "^4.1.0"}},++ "node_modules/picocolors": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",++ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",++ "license": "ISC"++ },++ "node_modules/postcss": {++ "version": "8.5.32",++ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",++ "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",++ "funding": [++ {++ "type": "opencollective",++ "url": "https://opencollective.com/postcss/"++ },++ {++ "type": "tidelift",++ "url": "https://tidelift.com/funding/github/npm/postcss"++ },++ {++ "type": "github",++ "url": "https://github.com/sponsors/ai"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "nanoid": "^3.3.20",++ "picocolors": "^1.1.1",++ "source-map-js": "^1.2.2"++ },++ "engines": {++ "node": "^10 || ^12 || >=14"++ }++ },"node_modules/postgres-array": {"version": "2.0.0","resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",@@@ -1354,6 +1449,45 @@"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==","license": "MIT"},++ "node_modules/sanitize-html": {++ "version": "2.18.1",++ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",++ "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",++ "license": "MIT",++ "dependencies": {++ "escape-string-regexp": "^4.0.0",++ "htmlparser2": "^12.0.0",++ "is-plain-object": "^5.1.0",++ "launder": "^1.7.2",++ "parse-srcset": "^1.0.2",++ "postcss": "^8.5.27"++ },++ "engines": {++ "node": ">=22.12.0"++ }++ },++ "node_modules/sanitize-html/node_modules/htmlparser2": {++ "version": "12.0.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",++ "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "domutils": "^4.0.2",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ }++ },"node_modules/semver": {"version": "7.8.6","resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",@@@ -1489,6 +1623,15 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/source-map-js": {++ "version": "1.2.2",++ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",++ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",++ "license": "BSD-3-Clause",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/split2": {"version": "4.2.0","resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",@@@ -30,7 +30,8 @@"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.142.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "sanitize-html": "^2.18.1"},"engines": {"node": ">=22.0.0"nnew file mode 100644@@@ -0,0 +1,116 @@++/* openvibe.email, your inbox (Mail M1.5, Opus's design). Desktop: addresses | the list | the message. Phone: the++ addresses as one row of chips, then the list; a message opens as its own screen (Back returns). The house's dark++ surfaces and one accent, as on the home page. */++main[data-mail-inbox] {++ --ib-card: var(--bg-card, #0f1729);++ --ib-input: var(--bg-input, #0d1424);++ --ib-line: var(--border, #1f2d47);++ --ib-text: var(--text-primary, #e6edf7);++ --ib-muted: var(--text-secondary, #96a7c2);++ --ib-faint: var(--text-muted, #7386a3);++ --ib-accent: var(--accent, #3b82f6);++ --ib-strong: var(--accent-strong, #3472d8);++ display: grid;++ grid-template-columns: 250px minmax(300px, 380px) minmax(0, 1fr);++ gap: 14px;++ box-sizing: border-box;++ height: calc(100dvh - var(--ovnav-h, 60px));++ max-width: 1500px;++ margin: 0 auto;++ padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px));++ color: var(--ib-text);++}++main[data-mail-inbox] * { box-sizing: border-box; }++main[data-mail-inbox] [hidden] { display: none !important; }++[data-mail-addresses], [data-mail-list], [data-mail-read] {++ min-width: 0; min-height: 0; overflow-y: auto; border-radius: 18px; border: 1px solid var(--ib-line); background: var(--ib-card); padding: 14px;++}++[data-mail-read] { padding: 18px 20px; }++.ib-h { margin: 0 0 10px; font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--ib-muted); }++.ib-quiet { margin: 18px 4px; color: var(--ib-muted); }++.ib-pick { text-align: center; margin-top: 30vh; }++.ib-problem { margin: 18px 4px; color: var(--danger-text, #f26262); font-weight: 600; }++.ib-btn {++ display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border: 0; border-radius: 999px;++ background: var(--ib-strong); color: var(--on-accent-strong, #fff); font: inherit; font-size: 14px; font-weight: 750; cursor: pointer;++}++.ib-btn.is-quiet { background: color-mix(in srgb, var(--ib-text) 8%, transparent); color: var(--ib-text); }++.ib-btn.is-danger { color: var(--danger-text, #f26262); }++.ib-btn:disabled { opacity: .6; cursor: default; }++++/* The addresses */++.ib-addrs { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 4px; }++.ib-addr {++ width: 100%; display: grid; gap: 2px; text-align: left; padding: 9px 12px; border: 0; border-radius: 12px;++ background: transparent; color: var(--ib-text); font: inherit; cursor: pointer;++}++.ib-addr:hover { background: color-mix(in srgb, var(--ib-text) 6%, transparent); }++.ib-addr.is-on { background: color-mix(in srgb, var(--ib-accent) 18%, transparent); }++.ib-addr-name { font-weight: 700; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ib-addr-sub { font-size: 12px; color: var(--ib-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ib-newmask { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding-top: 12px; border-top: 1px solid var(--ib-line); }++.ib-input { width: 100%; min-width: 0; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ib-line); background: var(--ib-input); color: var(--ib-text); font: inherit; }++.ib-made { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px 0 0; }++.ib-made-addr { font: 600 13px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; user-select: all; }++++/* The list */++.ib-rows { list-style: none; margin: 0; padding: 0; display: grid; }++.ib-row {++ width: 100%; display: grid; gap: 3px; text-align: left; padding: 11px 10px; border: 0; border-bottom: 1px solid var(--ib-line);++ background: transparent; color: var(--ib-text); font: inherit; cursor: pointer;++}++.ib-row:hover { background: color-mix(in srgb, var(--ib-text) 5%, transparent); }++.ib-row.is-open { background: color-mix(in srgb, var(--ib-accent) 14%, transparent); }++.ib-row-top { display: flex; align-items: baseline; gap: 10px; }++.ib-row-from { flex: 1; min-width: 0; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ib-row-when { flex: none; font-size: 12px; color: var(--ib-faint); font-variant-numeric: tabular-nums; }++.ib-row-subject { font-size: 13.5px; color: var(--ib-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ib-row-to { font-size: 12px; color: var(--ib-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ib-row.is-unread .ib-row-from { font-weight: 800; }++.ib-row.is-unread .ib-row-from::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--ib-accent); vertical-align: 1px; }++.ib-row.is-unread .ib-row-subject { color: var(--ib-text); }++.ib-clip { color: var(--ib-faint); }++.ib-more { margin: 12px auto 4px; display: flex; }++.ib-empty { display: grid; gap: 10px; justify-items: start; padding: 18px 6px; color: var(--ib-muted); }++.ib-empty p { margin: 0; }++.ib-empty-addr { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }++.ib-empty-addr code { font: 600 13px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--ib-text); overflow-wrap: anywhere; }++++/* One message */++.ib-readbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }++.ib-back { display: none; }++.ib-msghead { display: grid; gap: 6px; margin-bottom: 14px; }++.ib-subject { margin: 0; font-size: 1.3rem; line-height: 1.25; letter-spacing: -.01em; text-wrap: balance; overflow-wrap: anywhere; }++.ib-meta { margin: 0; font-size: 14px; color: var(--ib-muted); overflow-wrap: anywhere; }++.ib-meta b { color: var(--ib-text); }++.ib-images {++ display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0 0 12px; padding: 10px 12px; border-radius: 12px;++ background: color-mix(in srgb, var(--ib-accent) 10%, var(--ib-input)); color: var(--ib-muted); font-size: 13.5px;++}++.ib-frame { display: block; width: 100%; min-height: 160px; border: 0; border-radius: 12px; background: #fff; }++.ib-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.55; }++.ib-att { list-style: none; margin: 14px 0 0; padding: 12px 0 0; border-top: 1px solid var(--ib-line); display: grid; gap: 6px; }++.ib-att-link { font-weight: 650; color: var(--ib-accent); text-underline-offset: 2px; }++.ib-att-size { color: var(--ib-faint); font-size: 13px; }++++/* A phone: the addresses as one row of chips, then the list; a message is its own screen. */++@media (max-width: 860px) {++ main[data-mail-inbox] { display: flex; flex-direction: column; gap: 10px; height: auto; min-height: calc(100dvh - var(--ovnav-h, 60px)); padding: 10px 0 16px; }++ [data-mail-addresses], [data-mail-list], [data-mail-read] { border-radius: 0; border-left: 0; border-right: 0; overflow: visible; }++ [data-mail-addresses] { padding: 8px 0 10px; }++ [data-mail-addresses] .ib-h { display: none; }++ .ib-addrs { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin: 0; padding: 0 12px; }++ .ib-addrs::-webkit-scrollbar { display: none; }++ .ib-addrs li { flex: none; }++ .ib-addr { width: auto; max-width: 70vw; min-height: 40px; padding: 8px 14px; border-radius: 999px; background: color-mix(in srgb, var(--ib-text) 7%, transparent); }++ .ib-addr-sub { display: none; }++ .ib-newmask { grid-template-columns: minmax(0, 1fr) auto; margin: 10px 12px 0; padding-top: 10px; }++ .ib-made { margin: 8px 12px 0; }++ [data-mail-list] { padding: 8px 4px; }++ [data-mail-read] { display: none; padding: 12px 14px; }++ main[data-mail-inbox].is-reading [data-mail-read] { display: block; }++ main[data-mail-inbox].is-reading [data-mail-list], main[data-mail-inbox].is-reading [data-mail-addresses] { display: none; }++ .ib-back { display: inline-flex; }++ .ib-pick { display: none; }++ .ib-row { min-height: 64px; }++}@@@ -54,3 +54,6 @@.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; } }++++.ml-card-link { font-weight: 750; color: var(--accent, #3b82f6); text-decoration: none; }++.ml-card-link:hover, .ml-card-link:focus-visible { text-decoration: underline; }nnew file mode 100644@@@ -0,0 +1,287 @@++/* openvibe.email: your inbox (Mail M1.5, Opus's design). The page shell (server/http/pages.js, /inbox and++ /inbox/:id) gives <main data-mail-inbox> with three empty regions and the person's addresses as JSON in #mail-data:++++ - data-mail-addresses: All mail, then each address (your mailbox, your masks, your agents' inboxes) with what came++ to it; New mask makes one for a site in one line;++ - data-mail-list: the newest first, 50 at a time, from /api/v1/me/mail/messages;++ - data-mail-read: one message. Its HTML was cleaned on the server and is shown in a sandboxed frame that can run++ no script at all, with remote images off until you ask (they are how senders track opening).++++ On a phone the list and the message are two screens (Back returns), not tabs. Everything a sender wrote is set as++ text or goes into the sandboxed frame; nothing of it is ever parsed into this page. */++(function () {++ 'use strict';++ var root = document.querySelector('main[data-mail-inbox]');++ var dataEl = document.getElementById('mail-data');++ if (!root || !dataEl) return;++ var data;++ try { data = JSON.parse(dataEl.textContent || '{}'); } catch (e) { return; }++ var side = root.querySelector('[data-mail-addresses]');++ var listEl = root.querySelector('[data-mail-list]');++ var readEl = root.querySelector('[data-mail-read]');++ var addresses = Array.isArray(data.addresses) ? data.addresses : [];++ var current = 'all', cursor = null, rows = [], openId = null, loading = false;++ var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner' };++++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function api(path, opts) {++ opts = opts || {};++ return fetch(path, { method: opts.method || 'GET', credentials: 'same-origin', headers: Object.assign({ Accept: 'application/json' }, opts.body ? { 'Content-Type': 'application/json' } : {}), body: opts.body ? JSON.stringify(opts.body) : undefined })++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (b) { if (!r.ok) { var e = new Error(b.detail || b.title || ('HTTP ' + r.status)); e.status = r.status; throw e; } return b; }); });++ }++ function who(list) {++ var a = Array.isArray(list) ? list[0] : list;++ if (!a) return 'Unknown sender';++ if (typeof a === 'string') return a.replace(/\s*<[^>]+>\s*$/, '') || a;++ return a.name || a.email || 'Unknown sender';++ }++ function when(iso) {++ var d = new Date(iso);++ if (isNaN(d)) return '';++ var now = new Date();++ if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });++ if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });++ return d.toLocaleDateString([], { day: 'numeric', month: 'short', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });++ }++ function copy(text, button) {++ var done = function () { var t = button.textContent; button.textContent = 'Copied'; setTimeout(function () { button.textContent = t; }, 1400); };++ if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, function () {});++ }++ var narrow = window.matchMedia('(max-width: 860px)');++ function showRead(on) { root.classList.toggle('is-reading', !!on); }++++ // ── the addresses ──++ function paintSide() {++ side.textContent = '';++ side.appendChild(el('h2', 'ib-h', 'Addresses'));++ var ul = el('ul', 'ib-addrs');++ var all = [{ address: 'all', kind: 'all', label: 'All mail' }].concat(addresses);++ all.forEach(function (a) {++ var li = el('li');++ var b = el('button', 'ib-addr' + (current === a.address ? ' is-on' : ''));++ b.type = 'button';++ b.setAttribute('aria-pressed', current === a.address ? 'true' : 'false');++ b.appendChild(el('span', 'ib-addr-name', a.kind === 'all' ? 'All mail' : (a.label || a.address)));++ if (a.kind !== 'all') b.appendChild(el('span', 'ib-addr-sub', (KIND[a.kind] || 'Address') + (a.label ? ' · ' + a.address : '') + (a.enabled === false ? ' · off' : '')));++ b.addEventListener('click', function () { current = a.address; cursor = null; rows = []; paintSide(); loadList(); showRead(false); });++ li.appendChild(b);++ ul.appendChild(li);++ });++ side.appendChild(ul);++ // New mask: one line, for one site.++ var form = el('form', 'ib-newmask');++ var input = el('input', 'ib-input');++ input.type = 'text'; input.maxLength = 60; input.placeholder = 'A site, e.g. shoes shop'; input.setAttribute('aria-label', 'What the new mask is for');++ var go = el('button', 'ib-btn', 'New mask');++ go.type = 'submit';++ form.appendChild(input); form.appendChild(go);++ var made = el('p', 'ib-made');++ made.hidden = true;++ form.addEventListener('submit', function (e) {++ e.preventDefault();++ go.disabled = true;++ api('/api/v1/masks', { method: 'POST', body: { label: input.value.trim() || null } }).then(function (b) {++ var m = b.mask || {};++ addresses.push({ address: m.address, kind: 'mask', label: m.label || null, enabled: true });++ input.value = '';++ paintSide();++ var p = side.querySelector('.ib-made');++ p.hidden = false; p.textContent = '';++ p.appendChild(el('span', 'ib-made-addr', m.address));++ var c = el('button', 'ib-btn is-quiet', 'Copy');++ c.type = 'button';++ c.addEventListener('click', function () { copy(m.address, c); });++ p.appendChild(c);++ }).catch(function (err) { made.hidden = false; made.textContent = err.message; }).then(function () { go.disabled = false; });++ });++ side.appendChild(form);++ side.appendChild(made);++ }++++ // ── the list ──++ function loadList(more) {++ if (loading) return;++ loading = true;++ if (!more) { listEl.textContent = ''; listEl.appendChild(el('p', 'ib-quiet', 'Loading your mail…')); }++ var q = '?address=' + encodeURIComponent(current) + (more && cursor ? '&cursor=' + encodeURIComponent(cursor) : '');++ api('/api/v1/me/mail/messages' + q).then(function (b) {++ rows = (more ? rows : []).concat(b.messages || []);++ cursor = b.next_cursor || null;++ paintList();++ }).catch(function (err) {++ listEl.textContent = '';++ listEl.appendChild(el('p', 'ib-problem', err.status === 401 ? 'Sign in again to see your mail.' : 'Your mail could not be loaded. ' + err.message));++ }).then(function () { loading = false; });++ }++ function paintList() {++ listEl.textContent = '';++ var head = el('div', 'ib-listhead');++ var a = addresses.find(function (x) { return x.address === current; });++ head.appendChild(el('h2', 'ib-h', current === 'all' ? 'All mail' : (a && a.label) || current));++ listEl.appendChild(head);++ if (!rows.length) {++ var e = el('div', 'ib-empty');++ e.appendChild(el('p', null, current === 'all' ? 'Nothing here yet.' : 'Nothing has arrived at this address yet.'));++ if (current !== 'all') {++ var line = el('p', 'ib-empty-addr');++ line.appendChild(el('code', null, current));++ var c = el('button', 'ib-btn is-quiet', 'Copy');++ c.type = 'button';++ c.addEventListener('click', function () { copy(current, c); });++ line.appendChild(c);++ e.appendChild(line);++ }++ listEl.appendChild(e);++ return;++ }++ var ul = el('ul', 'ib-rows');++ rows.forEach(function (m) {++ var li = el('li');++ var b = el('button', 'ib-row' + (m.unread ? ' is-unread' : '') + (openId === m.id ? ' is-open' : ''));++ b.type = 'button';++ var top = el('span', 'ib-row-top');++ top.appendChild(el('span', 'ib-row-from', who(m.from)));++ top.appendChild(el('time', 'ib-row-when', when(m.received_at)));++ b.appendChild(top);++ var sub = el('span', 'ib-row-subject', m.subject || '(no subject)');++ if (m.has_attachments) sub.appendChild(el('span', 'ib-clip', ' · attachment'));++ b.appendChild(sub);++ if (current === 'all' && m.to) b.appendChild(el('span', 'ib-row-to', 'to ' + (Array.isArray(m.to) ? m.to[0] : m.to)));++ b.addEventListener('click', function () { openMessage(m.id, false); });++ li.appendChild(b);++ ul.appendChild(li);++ });++ listEl.appendChild(ul);++ if (cursor) {++ var more = el('button', 'ib-btn is-quiet ib-more', 'Older mail');++ more.type = 'button';++ more.addEventListener('click', function () { loadList(true); });++ listEl.appendChild(more);++ }++ }++++ // ── one message ──++ function openMessage(id, images) {++ openId = id;++ paintList();++ readEl.textContent = '';++ readEl.appendChild(el('p', 'ib-quiet', 'Opening…'));++ showRead(true);++ try { history.pushState({ mail: id }, '', '/inbox/' + encodeURIComponent(id)); } catch (e) { /* old browser */ }++ api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + (images ? '?images=1' : '')).then(function (b) {++ var m = b.message || {};++ paintMessage(m, images);++ var row = rows.find(function (r) { return r.id === id; });++ if (row && row.unread) { row.unread = false; paintList(); api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + '/read', { method: 'POST' }).catch(function () {}); }++ }).catch(function (err) {++ readEl.textContent = '';++ readEl.appendChild(el('p', 'ib-problem', err.status === 404 ? 'That message is not here any more.' : 'It could not be opened. ' + err.message));++ });++ }++ function paintMessage(m, images) {++ readEl.textContent = '';++ var bar = el('div', 'ib-readbar');++ var back = el('button', 'ib-btn is-quiet ib-back', '← Inbox');++ back.type = 'button';++ back.addEventListener('click', function () { showRead(false); try { history.back(); } catch (e) { /* */ } });++ bar.appendChild(back);++ var unread = el('button', 'ib-btn is-quiet', 'Mark unread');++ unread.type = 'button';++ unread.addEventListener('click', function () {++ api('/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/unread', { method: 'POST' }).then(function () {++ var row = rows.find(function (r) { return r.id === m.id; }); if (row) row.unread = true; paintList();++ }).catch(function () {});++ });++ var del = el('button', 'ib-btn is-quiet is-danger', 'Delete');++ del.type = 'button';++ del.addEventListener('click', function () {++ del.disabled = true;++ api('/api/v1/me/mail/messages/' + encodeURIComponent(m.id), { method: 'DELETE' }).then(function () {++ rows = rows.filter(function (r) { return r.id !== m.id; });++ openId = null; paintList(); readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet', 'Moved to the trash. It is gone for good after 30 days.')); showRead(false);++ }).catch(function () { del.disabled = false; });++ });++ bar.appendChild(unread); bar.appendChild(del);++ readEl.appendChild(bar);++ var head = el('header', 'ib-msghead');++ head.appendChild(el('h2', 'ib-subject', m.subject || '(no subject)'));++ var meta = el('p', 'ib-meta');++ meta.appendChild(el('b', null, who(m.from)));++ var to = Array.isArray(m.to) ? m.to.join(', ') : (m.to || '');++ if (to) meta.appendChild(document.createTextNode(' to ' + to));++ if (m.date) meta.appendChild(el('time', 'ib-meta-when', ' · ' + new Date(m.date).toLocaleString()));++ head.appendChild(meta);++ readEl.appendChild(head);++ if (m.blocked_images > 0 && !images) {++ var note = el('p', 'ib-images');++ note.appendChild(document.createTextNode(m.blocked_images + ' remote image' + (m.blocked_images === 1 ? ' is' : 's are') + ' off, so the sender cannot see that you opened this. '));++ var show = el('button', 'ib-btn is-quiet', 'Show images');++ show.type = 'button';++ show.addEventListener('click', function () { openMessage(m.id, true); });++ note.appendChild(show);++ readEl.appendChild(note);++ }++ if (m.html) readEl.appendChild(frameFor(m.html, images));++ else {++ var pre = el('div', 'ib-text');++ textNodes(pre, String(m.text || ''));++ readEl.appendChild(pre);++ }++ var att = Array.isArray(m.attachments) ? m.attachments : [];++ if (att.length) {++ var ul = el('ul', 'ib-att');++ att.forEach(function (a) {++ var li = el('li');++ var link = el('a', 'ib-att-link', a.name || 'attachment');++ link.href = '/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/attachments/' + encodeURIComponent(a.part);++ link.setAttribute('download', '');++ li.appendChild(link);++ if (a.size) li.appendChild(el('span', 'ib-att-size', ' · ' + Math.max(1, Math.round(a.size / 1024)) + ' KB'));++ ul.appendChild(li);++ });++ readEl.appendChild(ul);++ }++ }++ // The cleaned HTML in a frame that runs no script (no allow-scripts) and loads nothing remote unless asked; links++ // leave the frame in a new tab. Without scripts, allow-same-origin only lets this page size the frame to its text.++ function frameFor(html, images) {++ var f = el('iframe', 'ib-frame');++ f.setAttribute('sandbox', 'allow-same-origin allow-popups allow-popups-to-escape-sandbox');++ f.setAttribute('referrerpolicy', 'no-referrer');++ f.setAttribute('title', 'The message');++ var csp = "default-src 'none'; style-src 'unsafe-inline'; img-src data:" + (images ? ' https:' : '') + '; font-src data:';++ f.srcdoc = '<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="' + csp + '">' +++ '<base target="_blank"><style>html,body{margin:0;padding:0;background:#fff;color:#111;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;overflow-wrap:anywhere}body{padding:16px}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1d4ed8}</style></head><body>' + html + '</body></html>';++ f.addEventListener('load', function () {++ try { var h = f.contentDocument.documentElement.scrollHeight; f.style.height = Math.min(Math.max(h, 120), 20000) + 'px'; } catch (e) { f.style.height = '70vh'; }++ });++ return f;++ }++ function textNodes(parent, text) {++ var re = /https?:\/\/[^\s<>"']+/g, last = 0, mm;++ while ((mm = re.exec(text))) {++ if (mm.index > last) parent.appendChild(document.createTextNode(text.slice(last, mm.index)));++ var url = mm[0].replace(/[.,!?)]+$/, ''), a = el('a', null, url);++ a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';++ parent.appendChild(a); last = mm.index + url.length; re.lastIndex = last;++ }++ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));++ }++++ window.addEventListener('popstate', function () {++ var m = /^\/inbox\/([^/]+)$/.exec(location.pathname);++ if (m) openMessage(decodeURIComponent(m[1]), false); else { openId = null; showRead(false); paintList(); }++ });++++ // The addresses come from the API when the shell does not carry them.++ function begin() {++ paintSide();++ loadList();++ }++ if (addresses.length) begin();++ else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });++ var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);++ if (start) openMessage(decodeURIComponent(start[1]), false);++ else if (readEl) { readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet ib-pick', 'Pick a message to read it here.')); }++ narrow.addEventListener && narrow.addEventListener('change', function () { if (!narrow.matches) showRead(!!openId); });++})();nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * Reading your own mail (M1.5): a message's HTML made safe on the server, before the page puts it in a sandboxed++ * frame that runs no script anyway. Two layers, so one mistake is not a hole.++ *++ * The rule set is an allow-list (sanitize-html, MIT):++ * - tags: text, headings, lists, quotes, tables and images; no script, style, form, input, iframe, object, embed,++ * svg, math, link, meta or base;++ * - attributes: a few per tag (href, src, alt, sizes, table layout) and `style` with a short list of properties++ * whose values can never hold url(), expression() or an @import;++ * - schemes: links http, https and mailto; images https (only when the person asked) and data:image/*;++ * - links open in a new tab with rel="noopener noreferrer"; nothing keeps a window.opener.++ *++ * Remote images are how senders learn that a message was opened (and where, and when). They are replaced by their++ * alt text until the person asks for them, and the answer says how many were held back.++ *++ * Nothing here logs, stores or emits a body: the caller sends the result to its owner and forgets it.++ */++const sanitizeHtml = require('sanitize-html');++++const MAX_HTML_BYTES = 1024 * 1024;++const SAFE_VALUE = /^(?!.*(?:url\s*\(|expression\s*\(|@import|javascript:|behavior\s*:|-moz-binding))[^<>{};]*$/i;++const STYLE_PROPS = [++ 'color', 'background-color', 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',++ 'text-align', 'text-decoration', 'text-transform', 'letter-spacing', 'white-space', 'vertical-align',++ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',++ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',++ 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-color', 'border-width',++ 'border-style', 'border-radius', 'border-collapse', 'border-spacing',++ 'width', 'max-width', 'min-width', 'height', 'max-height', 'min-height', 'display',++];++const DATA_IMAGE = /^data:image\/(png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i;++++/**++ * The HTML a person may read: cleaned, links made safe, and remote images held back unless `images`.++ * Returns { html, blockedImages }.++ */++function cleanHtml(raw, { images = false } = {}) {++ let html = String(raw || '');++ if (Buffer.byteLength(html, 'utf8') > MAX_HTML_BYTES) html = html.slice(0, MAX_HTML_BYTES);++ let blocked = 0;++ const allowedStyles = { '*': Object.fromEntries(STYLE_PROPS.map((p) => [p, [SAFE_VALUE]])) };++ const out = sanitizeHtml(html, {++ allowedTags: [++ 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'col', 'colgroup', 'dd', 'del', 'div',++ 'dl', 'dt', 'em', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'mark',++ 'ol', 'p', 'pre', 'q', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody', 'td',++ 'tfoot', 'th', 'thead', 'tr', 'u', 'ul',++ ],++ disallowedTagsMode: 'discard',++ allowedAttributes: {++ a: ['href', 'title', 'target', 'rel'],++ img: ['src', 'alt', 'width', 'height', 'title'],++ td: ['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'bgcolor'],++ th: ['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'bgcolor'],++ table: ['width', 'cellpadding', 'cellspacing', 'border', 'align', 'bgcolor'],++ col: ['span', 'width'],++ font: ['color', 'size', 'face'],++ '*': ['style', 'dir', 'align', 'lang'],++ },++ allowedStyles,++ allowedSchemes: ['http', 'https', 'mailto'],++ allowedSchemesByTag: { img: ['https', 'data'] },++ allowedSchemesAppliedToAttributes: ['href', 'src'],++ allowProtocolRelative: false,++ transformTags: {++ a: (tagName, attribs) => {++ const href = String(attribs.href || '');++ const safe = /^(https?:|mailto:)/i.test(href) ? href : null;++ return { tagName: 'a', attribs: { ...(safe ? { href: safe } : {}), ...(attribs.title ? { title: attribs.title } : {}), target: '_blank', rel: 'noopener noreferrer' } };++ },++ img: (tagName, attribs) => {++ const src = String(attribs.src || '').trim();++ const alt = attribs.alt ? String(attribs.alt).slice(0, 200) : '';++ const size = { ...(attribs.width ? { width: attribs.width } : {}), ...(attribs.height ? { height: attribs.height } : {}) };++ if (DATA_IMAGE.test(src)) return { tagName: 'img', attribs: { src, alt, ...size } };++ if (/^https:\/\//i.test(src) && images) return { tagName: 'img', attribs: { src, alt, ...size } };++ if (/^https?:\/\//i.test(src) || src.startsWith('//')) blocked += 1;++ // Held back: its alt text in its place, so the layout still says what was there.++ return { tagName: 'span', attribs: {}, text: alt ? `[${alt}]` : '' };++ },++ },++ textFilter: (text) => text,++ });++ return { html: out, blockedImages: blocked };++}++++/** The attachment types a browser may be told; everything else downloads as bytes. */++const DOWNLOAD_TYPES = new Set([++ 'application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'text/plain', 'text/csv', 'text/calendar',++ 'application/zip', 'application/json', 'audio/mpeg', 'video/mp4',++ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',++ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',++ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',++]);++function downloadType(type) {++ const t = String(type || '').toLowerCase().split(';')[0].trim();++ return DOWNLOAD_TYPES.has(t) ? t : 'application/octet-stream';++}++/** A file name safe for Content-Disposition (ASCII fallback plus RFC 5987 filename*). */++function dispositionFor(name) {++ const clean = String(name || 'attachment').replace(/[\r\n"\\/]/g, '_').slice(0, 180) || 'attachment';++ const ascii = clean.replace(/[^\x20-\x7e]/g, '_');++ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean)}`;++}++++module.exports = { cleanHtml, downloadType, dispositionFor, MAX_HTML_BYTES, STYLE_PROPS };@@@ -267,6 +267,9 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {return res.json({ messages });}));++ // Reading your own mail (M1.5): server/http/reading-routes.js.++ require('./reading-routes').mountReading(router, { db, stalwart, principal, wrap, fail, ownerOf });++return router;}@@@ -106,6 +106,47 @@ document.addEventListener('DOMContentLoaded', function () {});}++/**++ * The inbox (M1.5): the shell public/js/inbox.js fills from /api/v1/me/mail/*. Its policy differs from the home page's++ * in one way: a message's frame inherits it, so img-src allows https images, which the frame's own policy keeps off++ * until the person asks to show them (public/js/inbox.js frameFor).++ */++const INBOX_CSP = CSP.replace("img-src 'self' data: https://openvibe.network https://openvibe.media", "img-src 'self' data: https:");++++function inboxPage(config) {++ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');++ const nav = {++ service: SERVICE,++ apiBase: NETWORK_URL,++ links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox', active: true }],++ history: { type: 'page', title: `Inbox · ${SITE_NAME}` },++ silentLogin: `${base}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ };++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: `Inbox · ${SITE_NAME}`,++ siteName: SITE_NAME,++ description: 'The mail that came to your mailbox, your masks and your agents.',++ canonical: `${base}/inbox`,++ robots: 'noindex, nofollow',++ home: '/',++ navLinks: nav.links,++ navbar: nav,++ head: `<meta name="referrer" content="no-referrer">++<link rel="stylesheet" href="${ovServe.url('kit.css')}">++<link rel="stylesheet" href="${asset('css/inbox.css')}">++<script src="${asset('js/inbox.js')}" defer></script>`,++ body: `<div id="navbar-mount"></div>++<main data-mail-inbox id="main"><section data-mail-addresses aria-label="Your addresses"></section><section data-mail-list aria-label="Messages"></section><section data-mail-read aria-label="The message"></section></main>++<script type="application/json" id="mail-data">{}</script>++<script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};</script>`,++ });++}++/** Mount the page, its files and Shared's. Before the API's 404 handler. */function mountPages(app, { config }) {app.use('/shared', ovServe.handler());@@@ -117,6 +158,15 @@ function mountPages(app, { config }) {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 inbox = null;++ app.get(['/inbox', '/inbox/:id'], (req, res) => {++ // A person's own mail: signed in, or sent to sign in and back here. The page is private and never cached.++ if (!(req.user && req.user.subject_id)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl || '/inbox')}`);++ inbox = inbox || inboxPage(config);++ res.set('Content-Security-Policy', INBOX_CSP);++ res.set('Cache-Control', 'private, no-store');++ res.type('html').send(inbox);++ });let html = null;app.get('/', (req, res) => {html = html || homePage(config);@@@ -126,4 +176,4 @@ function mountPages(app, { config }) {});}--module.exports = { mountPages, homePage, siteIdentity, CSP, asset };++module.exports = { mountPages, homePage, inboxPage, siteIdentity, CSP, INBOX_CSP, asset };nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++/**++ * Reading your own mail (M1.5): the person's mailbox and the addresses that deliver into it (masks, agent inboxes),++ * for its owner only. Another person's message is the same 404 as a missing one. Bodies go to the owner and nowhere++ * else: no log line, event or row carries one (test/reading.test.js captures the logger to prove it).++ *++ * GET /api/v1/me/mail/addresses the mailbox and the addresses that deliver into it++ * GET /api/v1/me/mail/messages?address=&cursor= a page, newest first (address: one of yours, or all)++ * GET /api/v1/me/mail/messages/:id[?images=1] one message, its HTML cleaned (server/domain/reading.js)++ * GET /api/v1/me/mail/messages/:id/attachments/:part the bytes, as a download++ * POST /api/v1/me/mail/messages/:id/read | /unread++ * DELETE /api/v1/me/mail/messages/:id to the Trash++ */++const { cleanHtml, downloadType, dispositionFor } = require('../domain/reading');++const masks = require('../domain/masks');++const agentInboxes = require('../domain/agent-inboxes');++const { mailboxAddressFor } = require('../domain/mailboxes');++++const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;++const PART_RE = /^[A-Za-z0-9._-]{1,64}$/;++++function mountReading(router, { db, stalwart, principal, wrap, fail, ownerOf }) {++ const requirePerson = principal.requirePerson;++ const read = requirePerson(principal.CAPABILITIES.read);++ const write = requirePerson(principal.CAPABILITIES.write);++ const noStore = (res) => res.set('Cache-Control', 'no-store');++++ /** The person's mailbox address and every address that delivers into it. */++ async function addressesOf(owner) {++ const [mailbox, maskRows, inboxes] = await Promise.all([++ mailboxAddressFor(db, owner),++ masks.listMaskRows({ db, owner }),++ agentInboxes.listAgentInboxes({ db, owner }),++ ]);++ const list = [];++ if (mailbox) list.push({ address: mailbox, kind: 'mailbox', label: null });++ for (const m of maskRows) list.push({ address: m.address, kind: 'mask', label: m.label || null, enabled: m.enabled !== false });++ for (const i of inboxes) list.push({ address: i.address, kind: 'agent', label: i.agent_id || null });++ return { mailbox, list };++ }++++ router.get('/api/v1/me/mail/addresses', read, wrap(async (req, res) => {++ const { mailbox, list } = await addressesOf(ownerOf(req));++ return noStore(res).json({ mailbox, addresses: list });++ }));++++ router.get('/api/v1/me/mail/messages', read, wrap(async (req, res) => {++ const owner = ownerOf(req);++ const { mailbox, list } = await addressesOf(owner);++ if (!mailbox) return noStore(res).json({ messages: [], next_cursor: null, mailbox: null });++ const asked = String(req.query.address || 'all').trim().toLowerCase();++ let recipient = null;++ if (asked !== 'all' && asked !== mailbox.toLowerCase()) {++ if (!list.some((a) => a.address.toLowerCase() === asked)) return fail(req, res, 404, 'mail.not_found', 'That address is not one of yours.');++ recipient = asked;++ }++ const cursor = /^\d{1,7}$/.test(String(req.query.cursor || '')) ? Number(req.query.cursor) : 0;++ const page = await stalwart.readMessages({ mailboxAddress: mailbox, recipient, position: cursor, limit: 50 });++ return noStore(res).json({++ messages: page.messages.map((m) => ({++ id: m.id, from: m.from, to: m.to, subject: m.subject, received_at: m.receivedAt,++ unread: m.unread, has_attachments: m.hasAttachment, spam: m.spam,++ })),++ next_cursor: page.next == null ? null : String(page.next),++ });++ }));++++ /** The person's mailbox, or an answer that says there is none. */++ async function mailboxOr404(req, res) {++ if (!ID_RE.test(String(req.params.id || ''))) { fail(req, res, 404, 'mail.not_found', 'That message is not here.'); return null; }++ const mailbox = await mailboxAddressFor(db, ownerOf(req));++ if (!mailbox) { fail(req, res, 404, 'mail.not_found', 'That message is not here.'); return null; }++ return mailbox;++ }++++ router.get('/api/v1/me/mail/messages/:id', read, wrap(async (req, res) => {++ const mailbox = await mailboxOr404(req, res);++ if (!mailbox) return undefined;++ const m = await stalwart.readMessage({ mailboxAddress: mailbox, id: req.params.id });++ if (!m) return fail(req, res, 404, 'mail.not_found', 'That message is not here.');++ const images = String(req.query.images || '') === '1';++ const cleaned = m.html ? cleanHtml(m.html, { images }) : { html: '', blockedImages: 0 };++ return noStore(res).json({++ message: {++ id: m.id, from: m.from, to: m.to, cc: m.cc, subject: m.subject, date: m.date, unread: m.unread,++ text: m.text || '', html: cleaned.html || null, blocked_images: cleaned.blockedImages,++ attachments: m.attachments.map((a) => ({ part: a.part, name: a.name, type: downloadType(a.type), size: a.size })),++ },++ });++ }));++++ router.get('/api/v1/me/mail/messages/:id/attachments/:part', read, wrap(async (req, res) => {++ const mailbox = await mailboxOr404(req, res);++ if (!mailbox) return undefined;++ if (!PART_RE.test(String(req.params.part || ''))) return fail(req, res, 404, 'mail.not_found', 'That attachment is not here.');++ let file;++ try {++ file = await stalwart.readAttachment({ mailboxAddress: mailbox, id: req.params.id, part: req.params.part });++ } catch (err) {++ if (err && err.status === 413) return fail(req, res, 413, 'mail.attachment_too_large', 'That attachment is too large to download here.');++ throw err;++ }++ if (!file) return fail(req, res, 404, 'mail.not_found', 'That attachment is not here.');++ noStore(res);++ res.set('Content-Type', downloadType(file.type));++ res.set('Content-Disposition', dispositionFor(file.name));++ res.set('X-Content-Type-Options', 'nosniff');++ res.set('Content-Security-Policy', "default-src 'none'; sandbox");++ return res.end(file.bytes);++ }));++++ for (const [path, seen] of [['read', true], ['unread', false]]) {++ router.post(`/api/v1/me/mail/messages/:id/${path}`, write, wrap(async (req, res) => {++ const mailbox = await mailboxOr404(req, res);++ if (!mailbox) return undefined;++ const ok = await stalwart.setSeen({ mailboxAddress: mailbox, id: req.params.id, seen });++ if (!ok) return fail(req, res, 404, 'mail.not_found', 'That message is not here.');++ return noStore(res).json({ id: req.params.id, unread: !seen });++ }));++ }++++ router.delete('/api/v1/me/mail/messages/:id', write, wrap(async (req, res) => {++ const mailbox = await mailboxOr404(req, res);++ if (!mailbox) return undefined;++ const ok = await stalwart.trashMessage({ mailboxAddress: mailbox, id: req.params.id });++ if (!ok) return fail(req, res, 404, 'mail.not_found', 'That message is not here.');++ return noStore(res).json({ id: req.params.id, trashed: true });++ }));++}++++module.exports = { mountReading };@@@ -296,11 +296,128 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = consreturn got.list || [];}++ // ---- reading your own mail (M1.5): the owner's page asks, the answer goes to them and is not kept ----++++ const READ_LIST_PROPS = ['id', 'from', 'to', 'subject', 'receivedAt', 'keywords', 'hasAttachment', 'headers'];++++ /**++ * A page of the person's mailbox, newest first. `recipient` keeps the messages the server stamped for one of their++ * addresses (a mask or an agent inbox); without it, every message in the mailbox. `position` is where the last page++ * stopped in the mailbox's own order. Answers { messages, next } (next is null at the end).++ */++ async function readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {++ const accountId = await accountIdFor(mailboxAddress);++ if (!accountId) return { messages: [], next: null };++ const wanted = recipient ? String(recipient).trim().toLowerCase() : null;++ const perPage = Math.min(Math.max(Number(limit) || 50, 1), 100);++ const scan = wanted ? 500 : perPage;++ const start = Math.max(0, Math.floor(Number(position) || 0));++ const queried = await one('Email/query', { accountId, filter: {}, sort: [{ property: 'receivedAt', isAscending: false }], position: start, limit: scan }, 'q');++ const ids = queried.ids || [];++ if (!ids.length) return { messages: [], next: null };++ const got = await one('Email/get', { accountId, ids, properties: READ_LIST_PROPS }, 'e');++ const byId = new Map((got.list || []).map((m) => [m.id, m]));++ const messages = [];++ let used = 0;++ for (const id of ids) {++ used += 1;++ const m = byId.get(id);++ if (!m) continue;++ const to = stampedRecipient(m);++ if (wanted && to !== wanted) continue;++ messages.push({++ id: m.id,++ from: (m.from || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean),++ to: to ? [to] : (m.to || []).map((a) => a.email).filter(Boolean).slice(0, 1),++ subject: m.subject || null,++ receivedAt: m.receivedAt || null,++ unread: !(m.keywords && m.keywords.$seen),++ hasAttachment: Boolean(m.hasAttachment),++ spam: Boolean(m.keywords && m.keywords.$junk),++ });++ if (messages.length >= perPage) break;++ }++ const more = used < ids.length || ids.length === scan;++ return { messages, next: more ? start + used : null };++ }++++ /**++ * One message for its owner to read: headers that matter, the text and HTML bodies (each at most 1 MB, raw: the++ * caller cleans the HTML), and the attachments (part id, name, type, size). Null when it is not in that mailbox.++ */++ async function readMessage({ mailboxAddress, id }) {++ const accountId = await accountIdFor(mailboxAddress);++ if (!accountId) return null;++ const got = await one('Email/get', {++ accountId, ids: [id],++ properties: ['id', 'from', 'to', 'cc', 'subject', 'receivedAt', 'sentAt', 'keywords', 'textBody', 'htmlBody', 'attachments', 'bodyValues'],++ fetchTextBodyValues: true, fetchHTMLBodyValues: true, maxBodyValueBytes: 1024 * 1024,++ }, 'e');++ const m = (got.list || [])[0];++ if (!m) return null;++ const values = m.bodyValues || {};++ const join = (parts, html) => (parts || [])++ .filter((p) => p && values[p.partId] && (html ? String(p.type || '').toLowerCase() === 'text/html' : String(p.type || '').toLowerCase() !== 'text/html'))++ .map((p) => values[p.partId].value).join('\n');++ const addr = (list) => (list || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean);++ return {++ id: m.id,++ from: addr(m.from), to: addr(m.to), cc: addr(m.cc),++ subject: m.subject || null,++ date: m.sentAt || m.receivedAt || null,++ unread: !(m.keywords && m.keywords.$seen),++ text: join(m.textBody, false),++ html: join(m.htmlBody, true),++ attachments: (m.attachments || []).filter((a) => a && a.blobId).map((a) => ({ part: String(a.partId || a.blobId), blobId: a.blobId, name: a.name || null, type: a.type || null, size: Number(a.size) || 0 })),++ };++ }++++ /** An attachment's bytes, when that part belongs to that message in that mailbox; null otherwise. */++ async function readAttachment({ mailboxAddress, id, part, maxBytes = 25 * 1024 * 1024 }) {++ const message = await readMessage({ mailboxAddress, id });++ if (!message) return null;++ const att = message.attachments.find((a) => a.part === String(part));++ if (!att) return null;++ if (att.size > maxBytes) throw new StalwartError('the attachment is too large to download here', { status: 413, code: 'mail.attachment_too_large' });++ const accountId = await accountIdFor(mailboxAddress);++ const blobs = await one('Blob/get', { accountId, ids: [att.blobId], properties: ['data:asBase64', 'size'] }, 'b');++ const blob = (blobs.list || [])[0];++ if (!blob || typeof blob['data:asBase64'] !== 'string') return null;++ return { name: att.name, type: att.type, bytes: Buffer.from(blob['data:asBase64'], 'base64') };++ }++++ /** Read or unread, on the person's own message. */++ async function setSeen({ mailboxAddress, id, seen }) {++ const accountId = await accountIdFor(mailboxAddress);++ if (!accountId) return false;++ const result = await one('Email/set', { accountId, update: { [id]: { 'keywords/$seen': seen ? true : null } } }, 's');++ return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));++ }++++ /** Move a message to the mailbox's Trash (Stalwart expunges it after its retention); false when it is not there. */++ async function trashMessage({ mailboxAddress, id }) {++ const accountId = await accountIdFor(mailboxAddress);++ if (!accountId) return false;++ const boxes = await one('Mailbox/query', { accountId, filter: { role: 'trash' } }, 'm');++ const trash = (boxes.ids || [])[0];++ if (!trash) {++ const result = await one('Email/set', { accountId, destroy: [id] }, 's');++ return (result.destroyed || []).includes(id);++ }++ const result = await one('Email/set', { accountId, update: { [id]: { mailboxIds: { [trash]: true } } } }, 's');++ return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));++ }++return {jmap,one,accountIdFor,listMessages,++ readMessages,++ readMessage,++ readAttachment,++ setSeen,++ trashMessage,deleteMessages,destroyAccount,messageBody,@@@ -34,8 +34,8 @@<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>++<article class="ml-card"><h3>Masks</h3><p>An address per site that delivers to your inbox, so a leak or a spammer never learns your real one. One switch turns it off.</p><p><a class="ml-card-link" href="/inbox">Make one in your inbox</a></p></article>++<article class="ml-card"><h3>Your inbox</h3><p>Read what came to your masks and your agents' inboxes, with trackers off until you say. Sending and your own domain come next.</p><p><a class="ml-card-link" href="/inbox">Open your inbox</a></p></article></div></section>@@@ -23,7 +23,7 @@ function createFakeStalwart() {mailboxes,/** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */-- deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [] }) {++ deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [], attachments = [] }) {counter += 1;const message = {id: `m${counter}`,@@@ -37,6 +37,7 @@ function createFakeStalwart() {{ name: 'Delivered-To', value: ` ${String(to).toLowerCase()}` },...headers,],++ attachments,};mailbox(mailboxAddress).push(message);return message;@@@ -80,6 +81,45 @@ function createFakeStalwart() {return { text: message.text, html: message.html };},++ // ---- reading (M1.5): the same rules as the real client (server/stalwart/client.js) ----++ async readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {++ const all = [...(mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [])].reverse();++ const wanted = recipient ? String(recipient).trim().toLowerCase() : null;++ const stamped = (m) => { const f = (m.headers || []).find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to'); return f ? String(f.value || '').trim().toLowerCase() : null; };++ const picked = [];++ let used = 0;++ for (const m of all.slice(position)) {++ used += 1;++ if (wanted && stamped(m) !== wanted) continue;++ picked.push({ id: m.id, from: m.from.map((a) => a.email), to: [stamped(m)], subject: m.subject, receivedAt: m.receivedAt, unread: !m.seen, hasAttachment: Boolean((m.attachments || []).length), spam: false });++ if (picked.length >= limit) break;++ }++ return { messages: picked, next: position + used < all.length ? position + used : null };++ },++ async readMessage({ mailboxAddress, id }) {++ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);++ if (!m) return null;++ return { id: m.id, from: m.from.map((a) => a.email), to: m.to.map((a) => a.email), cc: [], subject: m.subject, date: m.receivedAt, unread: !m.seen, text: m.text || '', html: m.html || '',++ attachments: (m.attachments || []).map((a) => ({ part: a.part, blobId: `b-${a.part}`, name: a.name, type: a.type, size: a.bytes.length })) };++ },++ async readAttachment({ mailboxAddress, id, part }) {++ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);++ const a = m && (m.attachments || []).find((x) => x.part === String(part));++ return a ? { name: a.name, type: a.type, bytes: a.bytes } : null;++ },++ async setSeen({ mailboxAddress, id, seen }) {++ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);++ if (!m) return false;++ m.seen = Boolean(seen);++ return true;++ },++ async trashMessage({ mailboxAddress, id }) {++ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);++ if (!m) return false;++ m.trashed = true;++ return true;++ },++async deleteMessages({ mailboxAddress, ids }) {calls.deleteMessages += 1;const key = String(mailboxAddress || '').toLowerCase();@@@ -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', '/release.json'].sort());++ assert.deepStrictEqual(proxied, ['/', '^/(css|js|shared)/', '^/inbox(/[A-Za-z0-9_-]+)?$', '/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,@@@ -24,8 +24,10 @@ test('/ is the page: the ten-minute address, the honest cards, the navbar, a strfor (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);++ // Masks and the inbox work since M1.5: no card says Later, and both lead to /inbox; sending is said to come next.++ assert.strictEqual((r.text.match(/class="ml-soon">Later</g) || []).length, 0);++ assert.strictEqual((r.text.match(/href="\/inbox"/g) || []).length, 2);++ assert.match(r.text, /Sending and your own domain come next/);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'/);nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * Reading your own mail (M1.5). The rules these tests hold: a person reads their mailbox and the addresses that++ * deliver into it, and nothing else; a message's HTML is cleaned on the server (no script, handler, form, frame or++ * javascript: link) with remote images held back and counted until asked; an attachment is a download with nosniff;++ * read, unread and trash work on one's own message only; no body ever reaches a log line.++ */++const test = require('node:test');++const assert = require('node:assert');++const { boot } = require('./helpers/boot');++const { cleanHtml, downloadType, dispositionFor } = require('../server/domain/reading');++++async function mailboxOf(s, person) {++ // A person's mailbox is made with their first address (a mask here).++ const made = await s.post('/api/v1/masks', { as: person, json: { label: 'shoes shop' } });++ assert.strictEqual(made.status, 201, JSON.stringify(made.body));++ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [person.subject]);++ return { mailbox, mask: made.body.mask };++}++++test('the cleaner keeps the letter and drops everything that could act', () => {++ const { html, blockedImages } = cleanHtml(`<p onclick="x()">Hi<script>steal()</script></p>++<a href="javascript:alert(1)">j</a><a href="https://shop.example/track">Track</a><a href="data:text/html,x">d</a>++<form action="https://x"><input name="p"></form><iframe src="https://x"></iframe><style>body{}</style><base href="https://evil">++<div style="background:url(https://t.example/x);color:red;position:fixed">s</div><p style="color: expression(alert(1))">e</p>++<img src="https://t.example/pixel.gif" alt="logo"><img src="//t.example/r.gif"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">`);++ for (const bad of ['script', 'onclick', 'javascript:', '<form', '<input', '<iframe', '<style', '<base', 'url(', 'expression', 'position', 'data:text/html']) {++ assert.ok(!html.includes(bad), `no ${bad}: ${html}`);++ }++ assert.match(html, /<a href="https:\/\/shop\.example\/track" target="_blank" rel="noopener noreferrer">Track<\/a>/);++ assert.match(html, /color:red/);++ assert.ok(!html.includes('t.example/pixel.gif'), 'a remote image is held back');++ assert.match(html, /\[logo\]/, 'its alt text stays in its place');++ assert.match(html, /data:image\/gif/, 'an image inside the message stays');++ assert.strictEqual(blockedImages, 2);++ const shown = cleanHtml('<img src="https://t.example/pixel.gif" alt="logo"><img src="http://t.example/q.gif">', { images: true });++ assert.match(shown.html, /src="https:\/\/t\.example\/pixel\.gif"/, 'asked for: an https image is shown');++ assert.ok(!shown.html.includes('http://t.example'), 'never a plain-http one');++});++++test('attachment types are an allow-list, names are safe in a header', () => {++ assert.strictEqual(downloadType('application/pdf'), 'application/pdf');++ assert.strictEqual(downloadType('text/html'), 'application/octet-stream');++ assert.strictEqual(downloadType('image/svg+xml'), 'application/octet-stream');++ assert.ok(!/[\r\n]/.test(dispositionFor('a\r\nSet-Cookie: x.pdf')));++ assert.match(dispositionFor('café.pdf'), /^attachment; filename="caf_\.pdf"; filename\*=UTF-8''caf%C3%A9\.pdf$/);++});++++test('a person reads their mailbox, one address at a time or all together', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const { mailbox, mask } = await mailboxOf(s, alice);++ s.mail.deliver({ mailboxAddress: mailbox, to: mask.address, from: '[email protected]', subject: 'Shipped', html: '<p>On its way</p>' });++ s.mail.deliver({ mailboxAddress: mailbox, to: mailbox, from: '[email protected]', subject: 'Friday film?', text: 'Popcorn?' });++++ const addrs = await s.get('/api/v1/me/mail/addresses', { as: alice });++ assert.strictEqual(addrs.status, 200);++ assert.strictEqual(addrs.body.mailbox, mailbox);++ assert.deepStrictEqual(addrs.body.addresses.map((a) => a.kind).sort(), ['mailbox', 'mask']);++++ const all = await s.get('/api/v1/me/mail/messages', { as: alice });++ assert.strictEqual(all.status, 200);++ assert.deepStrictEqual(all.body.messages.map((m) => m.subject), ['Friday film?', 'Shipped'], 'newest first');++ assert.strictEqual(all.body.messages[0].unread, true);++ assert.strictEqual(all.body.messages[0].text, undefined, 'a list carries no body');++++ const one = await s.get(`/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: alice });++ assert.deepStrictEqual(one.body.messages.map((m) => m.subject), ['Shipped'], 'the mask: only what the server stamped for it');++ const notMine = await s.get('/api/v1/me/mail/[email protected]', { as: alice });++ assert.strictEqual(notMine.status, 404);++});++++test('a message is read by its owner only, its HTML cleaned, and it can be marked and trashed', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const bob = s.network.addUser('bob');++ const { mailbox, mask } = await mailboxOf(s, alice);++ await mailboxOf(s, bob);++ const m = s.mail.deliver({ mailboxAddress: mailbox, to: mask.address, subject: 'Shipped',++ html: '<p onclick="x()">On its way<script>1</script></p><img src="https://t.example/p.gif" alt="logo">',++ attachments: [{ part: '2', name: 'invoice.pdf', type: 'application/pdf', bytes: Buffer.from('%PDF-1.4 test') },++ { part: '3', name: 'page.html', type: 'text/html', bytes: Buffer.from('<script>1</script>') }] });++++ const read = await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: alice });++ assert.strictEqual(read.status, 200);++ assert.match(read.headers.get('cache-control') || '', /no-store/);++ assert.ok(!/script|onclick|t\.example\/p\.gif/.test(read.body.message.html), read.body.message.html);++ assert.strictEqual(read.body.message.blocked_images, 1);++ assert.deepStrictEqual(read.body.message.attachments.map((a) => [a.name, a.type]), [['invoice.pdf', 'application/pdf'], ['page.html', 'application/octet-stream']]);++ const shown = await s.get(`/api/v1/me/mail/messages/${m.id}?images=1`, { as: alice });++ assert.match(shown.body.message.html, /t\.example\/p\.gif/);++++ assert.strictEqual((await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: bob })).status, 404, 'another person: not found');++++ const file = await s.get(`/api/v1/me/mail/messages/${m.id}/attachments/3`, { as: alice });++ assert.strictEqual(file.status, 200);++ assert.strictEqual(file.headers.get('content-type'), 'application/octet-stream', 'html never served as html');++ assert.match(file.headers.get('content-disposition') || '', /^attachment;/);++ assert.strictEqual(file.headers.get('x-content-type-options'), 'nosniff');++ assert.strictEqual((await s.get(`/api/v1/me/mail/messages/${m.id}/attachments/2`, { as: bob })).status, 404);++++ assert.strictEqual((await s.post(`/api/v1/me/mail/messages/${m.id}/read`, { as: alice })).status, 200);++ let list = await s.get('/api/v1/me/mail/messages', { as: alice });++ assert.strictEqual(list.body.messages[0].unread, false);++ assert.strictEqual((await s.post(`/api/v1/me/mail/messages/${m.id}/unread`, { as: alice })).status, 200);++ assert.strictEqual((await s.post(`/api/v1/me/mail/messages/${m.id}/read`, { as: bob })).status, 404);++++ assert.strictEqual((await s.del(`/api/v1/me/mail/messages/${m.id}`, { as: bob })).status, 404);++ assert.strictEqual((await s.del(`/api/v1/me/mail/messages/${m.id}`, { as: alice })).status, 200);++ assert.strictEqual((await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: alice })).status, 404, 'trashed: gone from the inbox');++});++++test('signed out, the inbox sends you to sign in; signed in it is private and never cached', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const out = await fetch(`${s.base}/inbox`, { redirect: 'manual' });++ assert.strictEqual(out.status, 302);++ assert.match(out.headers.get('location') || '', /^\/auth\/login\?next=%2Finbox/);++ const unauth = await s.get('/api/v1/me/mail/messages');++ assert.strictEqual(unauth.status, 401);++});++++test('no message body reaches a log line while reading', async (t) => {++ const s = await boot();++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const { mailbox, mask } = await mailboxOf(s, alice);++ const m = s.mail.deliver({ mailboxAddress: mailbox, to: mask.address, subject: 'Secret plans', text: 'the-body-marker-7731', html: '<p>the-html-marker-7731</p>' });++ await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: alice });++ await s.get('/api/v1/me/mail/messages', { as: alice });++ assert.ok(!s.logs().some((l) => /marker-7731/.test(l)), 'a body in a log line');++});@@@ -393,3 +393,38 @@ test('a ten-minute burner works without an account, read with its token', asyncconst without = await call('GET', `/api/v1/burners/${made.body.burner.id}/code`);assert.strictEqual(without.status, 404, 'the burner is not readable without its token');});++++test('reading your own mail (M1.5): a mask\'s HTML arrives cleaned, and it is marked and trashed', async () => {++ const reader = network.addUser('reader');++ const other = network.addUser('other-reader');++ const mask = (await call('POST', '/api/v1/masks', { as: reader, json: { label: 'shoes' } })).body.mask;++ await call('POST', '/api/v1/masks', { as: other, json: { label: 'other' } });++ await send({++ port: stalwart.smtpPort, to: mask.address, subject: 'Shipped',++ html: '<p onclick="x()">On its way<script>steal()</script></p><img src="https://t.example/pixel.gif" alt="logo"><a href="javascript:alert(1)">x</a>',++ });++ let list = null;++ for (let i = 0; i < 40; i++) {++ list = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });++ if (list.status === 200 && list.body.messages.length) break;++ await sleep(250);++ }++ assert.strictEqual(list.status, 200, JSON.stringify(list.body));++ assert.strictEqual(list.body.messages.length, 1, 'the mask has its message');++ const m = list.body.messages[0];++ assert.strictEqual(m.subject, 'Shipped');++ assert.strictEqual(m.unread, true);++++ const read = await call('GET', `/api/v1/me/mail/messages/${m.id}`, { as: reader });++ assert.strictEqual(read.status, 200, JSON.stringify(read.body));++ assert.ok(read.body.message.html && read.body.message.html.includes('On its way'), 'the html body came back from JMAP');++ assert.ok(!/script|onclick|javascript:|pixel\.gif/.test(read.body.message.html), read.body.message.html);++ assert.strictEqual(read.body.message.blocked_images, 1);++ assert.strictEqual((await call('GET', `/api/v1/me/mail/messages/${m.id}`, { as: other })).status, 404, 'another person');++++ assert.strictEqual((await call('POST', `/api/v1/me/mail/messages/${m.id}/read`, { as: reader })).status, 200);++ const after = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });++ assert.strictEqual(after.body.messages[0].unread, false, 'seen on the server');++ assert.strictEqual((await call('DELETE', `/api/v1/me/mail/messages/${m.id}`, { as: reader })).status, 200);++ assert.ok(!logs.some((l) => /On its way|steal\(\)/.test(String(l))), 'no body in the log');++});
@@ -6,6 +6,28 @@ M1 is **receive only**: Stalwart Community listens on port 25, the service hands
@@ -6,6 +6,28 @@ M1 is **receive only**: Stalwart Community listens on port 25, the service hands
OpenVibe mailboxes, and nothing here sends or relays mail. Sending, inbox AI and paid custom domains are M2–M4 in
OpenVibe mailboxes, and nothing here sends or relays mail. Sending, inbox AI and paid custom domains are M2–M4 in
the OpenVibe Seven Domains plan (2026-11-07) §8.
the OpenVibe Seven Domains plan (2026-11-07) §8.
+## M1.5: read your mail (2026-11-07)
+
+`/inbox` on openvibe.email shows a signed-in person's mailbox and the addresses that deliver into it (masks, agent
+inboxes), for its owner only (another person's message is the same 404 as a missing one):
+
+| Route | What |
+|---|---|
+| `GET /api/v1/me/mail/addresses` | the mailbox and the addresses that deliver into it |
+| `GET /api/v1/me/mail/messages?address=&cursor=` | a page, newest first; an address is matched on the server's `Delivered-To` stamp only |
+| `GET /api/v1/me/mail/messages/:id[?images=1]` | one message: text, and HTML cleaned on the server |
+| `GET /api/v1/me/mail/messages/:id/attachments/:part` | a download (`attachment`, `nosniff`, an allow-listed type or `application/octet-stream`) |
+| `POST …/:id/read`, `…/:id/unread`, `DELETE …/:id` | seen flags; Delete moves it to the Trash |
+
+**Two layers for a sender's HTML.** `server/domain/reading.js` cleans it with
+[sanitize-html](https://github.com/apostrophecms/sanitize-html) (MIT): an allow-list of tags and attributes, `style`
+limited to layout and colour properties whose values can hold no `url()`, `expression()` or `@import`, links only
+http(s)/mailto opening in a new tab with `rel="noopener noreferrer"`. Remote images, the way senders track opening,
+are replaced by their alt text and counted (`blocked_images`) until the person asks (`?images=1`, https only). The
+page (`public/js/inbox.js`) then shows the result in an iframe with no `allow-scripts` and its own CSP
+(`default-src 'none'`). Bodies go to their owner and nowhere else: never a log line, an event or a row
+(`test/reading.test.js` and the Stalwart acceptance test check the logger).
+
## What is in M1
## What is in M1
| Piece | What it does |
| Piece | What it does |
@@ -90,6 +90,11 @@ server {
@@ -90,6 +90,11 @@ server {
limit_except GET HEAD { deny all; }
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:5250;
proxy_pass http://127.0.0.1:5250;
}
}
+ # The inbox (M1.5): the page shell for a signed-in person; the mail itself comes from /api/v1/me/mail/*.
+ location ~ ^/inbox(/[A-Za-z0-9_-]+)?$ {
+ limit_except GET HEAD { deny all; }
+ proxy_pass http://127.0.0.1:5250;
+ }
# ── the machine answers ─────────────────────────────────
# ── the machine answers ─────────────────────────────────
location = /api/health {
location = /api/health {
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "sanitize-html": "^2.18.1"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8"
"@electric-sql/pglite": "^0.5.8"
@@ -243,6 +244,12 @@
@@ -243,6 +244,12 @@
"node": ">=20.0.0"
"node": ">=20.0.0"
}
}
},
},
+ "node_modules/dayjs": {
+ "version": "1.11.23",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -440,6 +447,18 @@
@@ -440,6 +447,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/etag": {
"node_modules/etag": {
"version": "1.8.1",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -813,6 +832,15 @@
@@ -813,6 +832,15 @@
"node": ">= 0.10"
"node": ">= 0.10"
}
}
},
},
+ "node_modules/is-plain-object": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
+ "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/json-schema-traverse": {
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -868,6 +896,15 @@
@@ -868,6 +896,15 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/launder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
+ "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
+ "license": "MIT",
+ "dependencies": {
+ "dayjs": "^1.11.23"
+ }
+ },
"node_modules/linkedom": {
"node_modules/linkedom": {
"version": "0.18.15",
"version": "0.18.15",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
@@ -1009,6 +1046,24 @@
@@ -1009,6 +1046,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/negotiator": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1119,6 +1174,12 @@
@@ -1119,6 +1174,12 @@
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
}
}
},
},
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1223,6 +1284,40 @@
@@ -1223,6 +1284,40 @@
"split2": "^4.1.0"
"split2": "^4.1.0"
}
}
},
},
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.32",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
+ "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/postgres-array": {
"node_modules/postgres-array": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1354,6 +1449,45 @@
@@ -1354,6 +1449,45 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/sanitize-html": {
+ "version": "2.18.1",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
+ "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^12.0.0",
+ "is-plain-object": "^5.1.0",
+ "launder": "^1.7.2",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.5.27"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/htmlparser2": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
+ "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "domutils": "^4.0.2",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/semver": {
"node_modules/semver": {
"version": "7.8.6",
"version": "7.8.6",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
@@ -1489,6 +1623,15 @@
@@ -1489,6 +1623,15 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/split2": {
"node_modules/split2": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -30,7 +30,8 @@
@@ -30,7 +30,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.329.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "sanitize-html": "^2.18.1"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+/* openvibe.email, your inbox (Mail M1.5, Opus's design). Desktop: addresses | the list | the message. Phone: the
+ addresses as one row of chips, then the list; a message opens as its own screen (Back returns). The house's dark
+ surfaces and one accent, as on the home page. */
+main[data-mail-inbox] {
+ --ib-card: var(--bg-card, #0f1729);
+ --ib-input: var(--bg-input, #0d1424);
+ --ib-line: var(--border, #1f2d47);
+ --ib-text: var(--text-primary, #e6edf7);
+ --ib-muted: var(--text-secondary, #96a7c2);
+ --ib-faint: var(--text-muted, #7386a3);
+ --ib-accent: var(--accent, #3b82f6);
+ --ib-strong: var(--accent-strong, #3472d8);
+ display: grid;
+ grid-template-columns: 250px minmax(300px, 380px) minmax(0, 1fr);
+ gap: 14px;
+ box-sizing: border-box;
+ height: calc(100dvh - var(--ovnav-h, 60px));
+ max-width: 1500px;
+ margin: 0 auto;
+ padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px));
+ color: var(--ib-text);
+}
+main[data-mail-inbox] * { box-sizing: border-box; }
+main[data-mail-inbox] [hidden] { display: none !important; }
+[data-mail-addresses], [data-mail-list], [data-mail-read] {
+ min-width: 0; min-height: 0; overflow-y: auto; border-radius: 18px; border: 1px solid var(--ib-line); background: var(--ib-card); padding: 14px;
+}
+[data-mail-read] { padding: 18px 20px; }
+.ib-h { margin: 0 0 10px; font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--ib-muted); }
+.ib-quiet { margin: 18px 4px; color: var(--ib-muted); }
+.ib-pick { text-align: center; margin-top: 30vh; }
+.ib-problem { margin: 18px 4px; color: var(--danger-text, #f26262); font-weight: 600; }
+.ib-btn {
+ display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border: 0; border-radius: 999px;
+ background: var(--ib-strong); color: var(--on-accent-strong, #fff); font: inherit; font-size: 14px; font-weight: 750; cursor: pointer;
+}
+.ib-btn.is-quiet { background: color-mix(in srgb, var(--ib-text) 8%, transparent); color: var(--ib-text); }
+.ib-btn.is-danger { color: var(--danger-text, #f26262); }
+.ib-btn:disabled { opacity: .6; cursor: default; }
+
+/* The addresses */
+.ib-addrs { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 4px; }
+.ib-addr {
+ width: 100%; display: grid; gap: 2px; text-align: left; padding: 9px 12px; border: 0; border-radius: 12px;
+ background: transparent; color: var(--ib-text); font: inherit; cursor: pointer;
+}
+.ib-addr:hover { background: color-mix(in srgb, var(--ib-text) 6%, transparent); }
+.ib-addr.is-on { background: color-mix(in srgb, var(--ib-accent) 18%, transparent); }
+.ib-addr-name { font-weight: 700; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ib-addr-sub { font-size: 12px; color: var(--ib-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ib-newmask { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding-top: 12px; border-top: 1px solid var(--ib-line); }
+.ib-input { width: 100%; min-width: 0; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ib-line); background: var(--ib-input); color: var(--ib-text); font: inherit; }
+.ib-made { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px 0 0; }
+.ib-made-addr { font: 600 13px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; user-select: all; }
+
+/* The list */
+.ib-rows { list-style: none; margin: 0; padding: 0; display: grid; }
+.ib-row {
+ width: 100%; display: grid; gap: 3px; text-align: left; padding: 11px 10px; border: 0; border-bottom: 1px solid var(--ib-line);
+ background: transparent; color: var(--ib-text); font: inherit; cursor: pointer;
+}
+.ib-row:hover { background: color-mix(in srgb, var(--ib-text) 5%, transparent); }
+.ib-row.is-open { background: color-mix(in srgb, var(--ib-accent) 14%, transparent); }
+.ib-row-top { display: flex; align-items: baseline; gap: 10px; }
+.ib-row-from { flex: 1; min-width: 0; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ib-row-when { flex: none; font-size: 12px; color: var(--ib-faint); font-variant-numeric: tabular-nums; }
+.ib-row-subject { font-size: 13.5px; color: var(--ib-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ib-row-to { font-size: 12px; color: var(--ib-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ib-row.is-unread .ib-row-from { font-weight: 800; }
+.ib-row.is-unread .ib-row-from::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--ib-accent); vertical-align: 1px; }
+.ib-row.is-unread .ib-row-subject { color: var(--ib-text); }
+.ib-clip { color: var(--ib-faint); }
+.ib-more { margin: 12px auto 4px; display: flex; }
+.ib-empty { display: grid; gap: 10px; justify-items: start; padding: 18px 6px; color: var(--ib-muted); }
+.ib-empty p { margin: 0; }
+.ib-empty-addr { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.ib-empty-addr code { font: 600 13px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--ib-text); overflow-wrap: anywhere; }
+
+/* One message */
+.ib-readbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
+.ib-back { display: none; }
+.ib-msghead { display: grid; gap: 6px; margin-bottom: 14px; }
+.ib-subject { margin: 0; font-size: 1.3rem; line-height: 1.25; letter-spacing: -.01em; text-wrap: balance; overflow-wrap: anywhere; }
+.ib-meta { margin: 0; font-size: 14px; color: var(--ib-muted); overflow-wrap: anywhere; }
+.ib-meta b { color: var(--ib-text); }
+.ib-images {
+ display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0 0 12px; padding: 10px 12px; border-radius: 12px;
+ background: color-mix(in srgb, var(--ib-accent) 10%, var(--ib-input)); color: var(--ib-muted); font-size: 13.5px;
+}
+.ib-frame { display: block; width: 100%; min-height: 160px; border: 0; border-radius: 12px; background: #fff; }
+.ib-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.55; }
+.ib-att { list-style: none; margin: 14px 0 0; padding: 12px 0 0; border-top: 1px solid var(--ib-line); display: grid; gap: 6px; }
+.ib-att-link { font-weight: 650; color: var(--ib-accent); text-underline-offset: 2px; }
+.ib-att-size { color: var(--ib-faint); font-size: 13px; }
+
+/* A phone: the addresses as one row of chips, then the list; a message is its own screen. */
+@media (max-width: 860px) {
+ main[data-mail-inbox] { display: flex; flex-direction: column; gap: 10px; height: auto; min-height: calc(100dvh - var(--ovnav-h, 60px)); padding: 10px 0 16px; }
+ [data-mail-addresses], [data-mail-list], [data-mail-read] { border-radius: 0; border-left: 0; border-right: 0; overflow: visible; }
+ [data-mail-addresses] { padding: 8px 0 10px; }
+ [data-mail-addresses] .ib-h { display: none; }
+ .ib-addrs { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin: 0; padding: 0 12px; }
+ .ib-addrs::-webkit-scrollbar { display: none; }
+ .ib-addrs li { flex: none; }
+ .ib-addr { width: auto; max-width: 70vw; min-height: 40px; padding: 8px 14px; border-radius: 999px; background: color-mix(in srgb, var(--ib-text) 7%, transparent); }
+ .ib-addr-sub { display: none; }
+ .ib-newmask { grid-template-columns: minmax(0, 1fr) auto; margin: 10px 12px 0; padding-top: 10px; }
+ .ib-made { margin: 8px 12px 0; }
+ [data-mail-list] { padding: 8px 4px; }
+ [data-mail-read] { display: none; padding: 12px 14px; }
+ main[data-mail-inbox].is-reading [data-mail-read] { display: block; }
+ main[data-mail-inbox].is-reading [data-mail-list], main[data-mail-inbox].is-reading [data-mail-addresses] { display: none; }
+ .ib-back { display: inline-flex; }
+ .ib-pick { display: none; }
+ .ib-row { min-height: 64px; }
+}
@@ -54,3 +54,6 @@
@@ -54,3 +54,6 @@
.ml-h2 { margin: 0 0 10px; font-size: 1.05rem; }
.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; }
.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; } }
@media (prefers-reduced-motion: reduce) { .ml-btn { transition: none; } }
+
+.ml-card-link { font-weight: 750; color: var(--accent, #3b82f6); text-decoration: none; }
+.ml-card-link:hover, .ml-card-link:focus-visible { text-decoration: underline; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+/* openvibe.email: your inbox (Mail M1.5, Opus's design). The page shell (server/http/pages.js, /inbox and
+ /inbox/:id) gives <main data-mail-inbox> with three empty regions and the person's addresses as JSON in #mail-data:
+
+ - data-mail-addresses: All mail, then each address (your mailbox, your masks, your agents' inboxes) with what came
+ to it; New mask makes one for a site in one line;
+ - data-mail-list: the newest first, 50 at a time, from /api/v1/me/mail/messages;
+ - data-mail-read: one message. Its HTML was cleaned on the server and is shown in a sandboxed frame that can run
+ no script at all, with remote images off until you ask (they are how senders track opening).
+
+ On a phone the list and the message are two screens (Back returns), not tabs. Everything a sender wrote is set as
+ text or goes into the sandboxed frame; nothing of it is ever parsed into this page. */
+(function () {
+ 'use strict';
+ var root = document.querySelector('main[data-mail-inbox]');
+ var dataEl = document.getElementById('mail-data');
+ if (!root || !dataEl) return;
+ var data;
+ try { data = JSON.parse(dataEl.textContent || '{}'); } catch (e) { return; }
+ var side = root.querySelector('[data-mail-addresses]');
+ var listEl = root.querySelector('[data-mail-list]');
+ var readEl = root.querySelector('[data-mail-read]');
+ var addresses = Array.isArray(data.addresses) ? data.addresses : [];
+ var current = 'all', cursor = null, rows = [], openId = null, loading = false;
+ var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner' };
+
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function api(path, opts) {
+ opts = opts || {};
+ return fetch(path, { method: opts.method || 'GET', credentials: 'same-origin', headers: Object.assign({ Accept: 'application/json' }, opts.body ? { 'Content-Type': 'application/json' } : {}), body: opts.body ? JSON.stringify(opts.body) : undefined })
+ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (b) { if (!r.ok) { var e = new Error(b.detail || b.title || ('HTTP ' + r.status)); e.status = r.status; throw e; } return b; }); });
+ }
+ function who(list) {
+ var a = Array.isArray(list) ? list[0] : list;
+ if (!a) return 'Unknown sender';
+ if (typeof a === 'string') return a.replace(/\s*<[^>]+>\s*$/, '') || a;
+ return a.name || a.email || 'Unknown sender';
+ }
+ function when(iso) {
+ var d = new Date(iso);
+ if (isNaN(d)) return '';
+ var now = new Date();
+ if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
+ return d.toLocaleDateString([], { day: 'numeric', month: 'short', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
+ }
+ function copy(text, button) {
+ var done = function () { var t = button.textContent; button.textContent = 'Copied'; setTimeout(function () { button.textContent = t; }, 1400); };
+ if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, function () {});
+ }
+ var narrow = window.matchMedia('(max-width: 860px)');
+ function showRead(on) { root.classList.toggle('is-reading', !!on); }
+
+ // ── the addresses ──
+ function paintSide() {
+ side.textContent = '';
+ side.appendChild(el('h2', 'ib-h', 'Addresses'));
+ var ul = el('ul', 'ib-addrs');
+ var all = [{ address: 'all', kind: 'all', label: 'All mail' }].concat(addresses);
+ all.forEach(function (a) {
+ var li = el('li');
+ var b = el('button', 'ib-addr' + (current === a.address ? ' is-on' : ''));
+ b.type = 'button';
+ b.setAttribute('aria-pressed', current === a.address ? 'true' : 'false');
+ b.appendChild(el('span', 'ib-addr-name', a.kind === 'all' ? 'All mail' : (a.label || a.address)));
+ if (a.kind !== 'all') b.appendChild(el('span', 'ib-addr-sub', (KIND[a.kind] || 'Address') + (a.label ? ' · ' + a.address : '') + (a.enabled === false ? ' · off' : '')));
+ b.addEventListener('click', function () { current = a.address; cursor = null; rows = []; paintSide(); loadList(); showRead(false); });
+ li.appendChild(b);
+ ul.appendChild(li);
+ });
+ side.appendChild(ul);
+ // New mask: one line, for one site.
+ var form = el('form', 'ib-newmask');
+ var input = el('input', 'ib-input');
+ input.type = 'text'; input.maxLength = 60; input.placeholder = 'A site, e.g. shoes shop'; input.setAttribute('aria-label', 'What the new mask is for');
+ var go = el('button', 'ib-btn', 'New mask');
+ go.type = 'submit';
+ form.appendChild(input); form.appendChild(go);
+ var made = el('p', 'ib-made');
+ made.hidden = true;
+ form.addEventListener('submit', function (e) {
+ e.preventDefault();
+ go.disabled = true;
+ api('/api/v1/masks', { method: 'POST', body: { label: input.value.trim() || null } }).then(function (b) {
+ var m = b.mask || {};
+ addresses.push({ address: m.address, kind: 'mask', label: m.label || null, enabled: true });
+ input.value = '';
+ paintSide();
+ var p = side.querySelector('.ib-made');
+ p.hidden = false; p.textContent = '';
+ p.appendChild(el('span', 'ib-made-addr', m.address));
+ var c = el('button', 'ib-btn is-quiet', 'Copy');
+ c.type = 'button';
+ c.addEventListener('click', function () { copy(m.address, c); });
+ p.appendChild(c);
+ }).catch(function (err) { made.hidden = false; made.textContent = err.message; }).then(function () { go.disabled = false; });
+ });
+ side.appendChild(form);
+ side.appendChild(made);
+ }
+
+ // ── the list ──
+ function loadList(more) {
+ if (loading) return;
+ loading = true;
+ if (!more) { listEl.textContent = ''; listEl.appendChild(el('p', 'ib-quiet', 'Loading your mail…')); }
+ var q = '?address=' + encodeURIComponent(current) + (more && cursor ? '&cursor=' + encodeURIComponent(cursor) : '');
+ api('/api/v1/me/mail/messages' + q).then(function (b) {
+ rows = (more ? rows : []).concat(b.messages || []);
+ cursor = b.next_cursor || null;
+ paintList();
+ }).catch(function (err) {
+ listEl.textContent = '';
+ listEl.appendChild(el('p', 'ib-problem', err.status === 401 ? 'Sign in again to see your mail.' : 'Your mail could not be loaded. ' + err.message));
+ }).then(function () { loading = false; });
+ }
+ function paintList() {
+ listEl.textContent = '';
+ var head = el('div', 'ib-listhead');
+ var a = addresses.find(function (x) { return x.address === current; });
+ head.appendChild(el('h2', 'ib-h', current === 'all' ? 'All mail' : (a && a.label) || current));
+ listEl.appendChild(head);
+ if (!rows.length) {
+ var e = el('div', 'ib-empty');
+ e.appendChild(el('p', null, current === 'all' ? 'Nothing here yet.' : 'Nothing has arrived at this address yet.'));
+ if (current !== 'all') {
+ var line = el('p', 'ib-empty-addr');
+ line.appendChild(el('code', null, current));
+ var c = el('button', 'ib-btn is-quiet', 'Copy');
+ c.type = 'button';
+ c.addEventListener('click', function () { copy(current, c); });
+ line.appendChild(c);
+ e.appendChild(line);
+ }
+ listEl.appendChild(e);
+ return;
+ }
+ var ul = el('ul', 'ib-rows');
+ rows.forEach(function (m) {
+ var li = el('li');
+ var b = el('button', 'ib-row' + (m.unread ? ' is-unread' : '') + (openId === m.id ? ' is-open' : ''));
+ b.type = 'button';
+ var top = el('span', 'ib-row-top');
+ top.appendChild(el('span', 'ib-row-from', who(m.from)));
+ top.appendChild(el('time', 'ib-row-when', when(m.received_at)));
+ b.appendChild(top);
+ var sub = el('span', 'ib-row-subject', m.subject || '(no subject)');
+ if (m.has_attachments) sub.appendChild(el('span', 'ib-clip', ' · attachment'));
+ b.appendChild(sub);
+ if (current === 'all' && m.to) b.appendChild(el('span', 'ib-row-to', 'to ' + (Array.isArray(m.to) ? m.to[0] : m.to)));
+ b.addEventListener('click', function () { openMessage(m.id, false); });
+ li.appendChild(b);
+ ul.appendChild(li);
+ });
+ listEl.appendChild(ul);
+ if (cursor) {
+ var more = el('button', 'ib-btn is-quiet ib-more', 'Older mail');
+ more.type = 'button';
+ more.addEventListener('click', function () { loadList(true); });
+ listEl.appendChild(more);
+ }
+ }
+
+ // ── one message ──
+ function openMessage(id, images) {
+ openId = id;
+ paintList();
+ readEl.textContent = '';
+ readEl.appendChild(el('p', 'ib-quiet', 'Opening…'));
+ showRead(true);
+ try { history.pushState({ mail: id }, '', '/inbox/' + encodeURIComponent(id)); } catch (e) { /* old browser */ }
+ api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + (images ? '?images=1' : '')).then(function (b) {
+ var m = b.message || {};
+ paintMessage(m, images);
+ var row = rows.find(function (r) { return r.id === id; });
+ if (row && row.unread) { row.unread = false; paintList(); api('/api/v1/me/mail/messages/' + encodeURIComponent(id) + '/read', { method: 'POST' }).catch(function () {}); }
+ }).catch(function (err) {
+ readEl.textContent = '';
+ readEl.appendChild(el('p', 'ib-problem', err.status === 404 ? 'That message is not here any more.' : 'It could not be opened. ' + err.message));
+ });
+ }
+ function paintMessage(m, images) {
+ readEl.textContent = '';
+ var bar = el('div', 'ib-readbar');
+ var back = el('button', 'ib-btn is-quiet ib-back', '← Inbox');
+ back.type = 'button';
+ back.addEventListener('click', function () { showRead(false); try { history.back(); } catch (e) { /* */ } });
+ bar.appendChild(back);
+ var unread = el('button', 'ib-btn is-quiet', 'Mark unread');
+ unread.type = 'button';
+ unread.addEventListener('click', function () {
+ api('/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/unread', { method: 'POST' }).then(function () {
+ var row = rows.find(function (r) { return r.id === m.id; }); if (row) row.unread = true; paintList();
+ }).catch(function () {});
+ });
+ var del = el('button', 'ib-btn is-quiet is-danger', 'Delete');
+ del.type = 'button';
+ del.addEventListener('click', function () {
+ del.disabled = true;
+ api('/api/v1/me/mail/messages/' + encodeURIComponent(m.id), { method: 'DELETE' }).then(function () {
+ rows = rows.filter(function (r) { return r.id !== m.id; });
+ openId = null; paintList(); readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet', 'Moved to the trash. It is gone for good after 30 days.')); showRead(false);
+ }).catch(function () { del.disabled = false; });
+ });
+ bar.appendChild(unread); bar.appendChild(del);
+ readEl.appendChild(bar);
+ var head = el('header', 'ib-msghead');
+ head.appendChild(el('h2', 'ib-subject', m.subject || '(no subject)'));
+ var meta = el('p', 'ib-meta');
+ meta.appendChild(el('b', null, who(m.from)));
+ var to = Array.isArray(m.to) ? m.to.join(', ') : (m.to || '');
+ if (to) meta.appendChild(document.createTextNode(' to ' + to));
+ if (m.date) meta.appendChild(el('time', 'ib-meta-when', ' · ' + new Date(m.date).toLocaleString()));
+ head.appendChild(meta);
+ readEl.appendChild(head);
+ if (m.blocked_images > 0 && !images) {
+ var note = el('p', 'ib-images');
+ note.appendChild(document.createTextNode(m.blocked_images + ' remote image' + (m.blocked_images === 1 ? ' is' : 's are') + ' off, so the sender cannot see that you opened this. '));
+ var show = el('button', 'ib-btn is-quiet', 'Show images');
+ show.type = 'button';
+ show.addEventListener('click', function () { openMessage(m.id, true); });
+ note.appendChild(show);
+ readEl.appendChild(note);
+ }
+ if (m.html) readEl.appendChild(frameFor(m.html, images));
+ else {
+ var pre = el('div', 'ib-text');
+ textNodes(pre, String(m.text || ''));
+ readEl.appendChild(pre);
+ }
+ var att = Array.isArray(m.attachments) ? m.attachments : [];
+ if (att.length) {
+ var ul = el('ul', 'ib-att');
+ att.forEach(function (a) {
+ var li = el('li');
+ var link = el('a', 'ib-att-link', a.name || 'attachment');
+ link.href = '/api/v1/me/mail/messages/' + encodeURIComponent(m.id) + '/attachments/' + encodeURIComponent(a.part);
+ link.setAttribute('download', '');
+ li.appendChild(link);
+ if (a.size) li.appendChild(el('span', 'ib-att-size', ' · ' + Math.max(1, Math.round(a.size / 1024)) + ' KB'));
+ ul.appendChild(li);
+ });
+ readEl.appendChild(ul);
+ }
+ }
+ // The cleaned HTML in a frame that runs no script (no allow-scripts) and loads nothing remote unless asked; links
+ // leave the frame in a new tab. Without scripts, allow-same-origin only lets this page size the frame to its text.
+ function frameFor(html, images) {
+ var f = el('iframe', 'ib-frame');
+ f.setAttribute('sandbox', 'allow-same-origin allow-popups allow-popups-to-escape-sandbox');
+ f.setAttribute('referrerpolicy', 'no-referrer');
+ f.setAttribute('title', 'The message');
+ var csp = "default-src 'none'; style-src 'unsafe-inline'; img-src data:" + (images ? ' https:' : '') + '; font-src data:';
+ f.srcdoc = '<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="' + csp + '">' +
+ '<base target="_blank"><style>html,body{margin:0;padding:0;background:#fff;color:#111;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;overflow-wrap:anywhere}body{padding:16px}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1d4ed8}</style></head><body>' + html + '</body></html>';
+ f.addEventListener('load', function () {
+ try { var h = f.contentDocument.documentElement.scrollHeight; f.style.height = Math.min(Math.max(h, 120), 20000) + 'px'; } catch (e) { f.style.height = '70vh'; }
+ });
+ return f;
+ }
+ function textNodes(parent, text) {
+ var re = /https?:\/\/[^\s<>"']+/g, last = 0, mm;
+ while ((mm = re.exec(text))) {
+ if (mm.index > last) parent.appendChild(document.createTextNode(text.slice(last, mm.index)));
+ var url = mm[0].replace(/[.,!?)]+$/, ''), a = el('a', null, url);
+ a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
+ parent.appendChild(a); last = mm.index + url.length; re.lastIndex = last;
+ }
+ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
+ }
+
+ window.addEventListener('popstate', function () {
+ var m = /^\/inbox\/([^/]+)$/.exec(location.pathname);
+ if (m) openMessage(decodeURIComponent(m[1]), false); else { openId = null; showRead(false); paintList(); }
+ });
+
+ // The addresses come from the API when the shell does not carry them.
+ function begin() {
+ paintSide();
+ loadList();
+ }
+ if (addresses.length) begin();
+ else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });
+ var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);
+ if (start) openMessage(decodeURIComponent(start[1]), false);
+ else if (readEl) { readEl.textContent = ''; readEl.appendChild(el('p', 'ib-quiet ib-pick', 'Pick a message to read it here.')); }
+ narrow.addEventListener && narrow.addEventListener('change', function () { if (!narrow.matches) showRead(!!openId); });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * Reading your own mail (M1.5): a message's HTML made safe on the server, before the page puts it in a sandboxed
+ * frame that runs no script anyway. Two layers, so one mistake is not a hole.
+ *
+ * The rule set is an allow-list (sanitize-html, MIT):
+ * - tags: text, headings, lists, quotes, tables and images; no script, style, form, input, iframe, object, embed,
+ * svg, math, link, meta or base;
+ * - attributes: a few per tag (href, src, alt, sizes, table layout) and `style` with a short list of properties
+ * whose values can never hold url(), expression() or an @import;
+ * - schemes: links http, https and mailto; images https (only when the person asked) and data:image/*;
+ * - links open in a new tab with rel="noopener noreferrer"; nothing keeps a window.opener.
+ *
+ * Remote images are how senders learn that a message was opened (and where, and when). They are replaced by their
+ * alt text until the person asks for them, and the answer says how many were held back.
+ *
+ * Nothing here logs, stores or emits a body: the caller sends the result to its owner and forgets it.
+ */
+const sanitizeHtml = require('sanitize-html');
+
+const MAX_HTML_BYTES = 1024 * 1024;
+const SAFE_VALUE = /^(?!.*(?:url\s*\(|expression\s*\(|@import|javascript:|behavior\s*:|-moz-binding))[^<>{};]*$/i;
+const STYLE_PROPS = [
+ 'color', 'background-color', 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
+ 'text-align', 'text-decoration', 'text-transform', 'letter-spacing', 'white-space', 'vertical-align',
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
+ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
+ 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-color', 'border-width',
+ 'border-style', 'border-radius', 'border-collapse', 'border-spacing',
+ 'width', 'max-width', 'min-width', 'height', 'max-height', 'min-height', 'display',
+];
+const DATA_IMAGE = /^data:image\/(png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i;
+
+/**
+ * The HTML a person may read: cleaned, links made safe, and remote images held back unless `images`.
+ * Returns { html, blockedImages }.
+ */
+function cleanHtml(raw, { images = false } = {}) {
+ let html = String(raw || '');
+ if (Buffer.byteLength(html, 'utf8') > MAX_HTML_BYTES) html = html.slice(0, MAX_HTML_BYTES);
+ let blocked = 0;
+ const allowedStyles = { '*': Object.fromEntries(STYLE_PROPS.map((p) => [p, [SAFE_VALUE]])) };
+ const out = sanitizeHtml(html, {
+ allowedTags: [
+ 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'col', 'colgroup', 'dd', 'del', 'div',
+ 'dl', 'dt', 'em', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'mark',
+ 'ol', 'p', 'pre', 'q', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody', 'td',
+ 'tfoot', 'th', 'thead', 'tr', 'u', 'ul',
+ ],
+ disallowedTagsMode: 'discard',
+ allowedAttributes: {
+ a: ['href', 'title', 'target', 'rel'],
+ img: ['src', 'alt', 'width', 'height', 'title'],
+ td: ['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'bgcolor'],
+ th: ['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'bgcolor'],
+ table: ['width', 'cellpadding', 'cellspacing', 'border', 'align', 'bgcolor'],
+ col: ['span', 'width'],
+ font: ['color', 'size', 'face'],
+ '*': ['style', 'dir', 'align', 'lang'],
+ },
+ allowedStyles,
+ allowedSchemes: ['http', 'https', 'mailto'],
+ allowedSchemesByTag: { img: ['https', 'data'] },
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
+ allowProtocolRelative: false,
+ transformTags: {
+ a: (tagName, attribs) => {
+ const href = String(attribs.href || '');
+ const safe = /^(https?:|mailto:)/i.test(href) ? href : null;
+ return { tagName: 'a', attribs: { ...(safe ? { href: safe } : {}), ...(attribs.title ? { title: attribs.title } : {}), target: '_blank', rel: 'noopener noreferrer' } };
+ },
+ img: (tagName, attribs) => {
+ const src = String(attribs.src || '').trim();
+ const alt = attribs.alt ? String(attribs.alt).slice(0, 200) : '';
+ const size = { ...(attribs.width ? { width: attribs.width } : {}), ...(attribs.height ? { height: attribs.height } : {}) };
+ if (DATA_IMAGE.test(src)) return { tagName: 'img', attribs: { src, alt, ...size } };
+ if (/^https:\/\//i.test(src) && images) return { tagName: 'img', attribs: { src, alt, ...size } };
+ if (/^https?:\/\//i.test(src) || src.startsWith('//')) blocked += 1;
+ // Held back: its alt text in its place, so the layout still says what was there.
+ return { tagName: 'span', attribs: {}, text: alt ? `[${alt}]` : '' };
+ },
+ },
+ textFilter: (text) => text,
+ });
+ return { html: out, blockedImages: blocked };
+}
+
+/** The attachment types a browser may be told; everything else downloads as bytes. */
+const DOWNLOAD_TYPES = new Set([
+ 'application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'text/plain', 'text/csv', 'text/calendar',
+ 'application/zip', 'application/json', 'audio/mpeg', 'video/mp4',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+]);
+function downloadType(type) {
+ const t = String(type || '').toLowerCase().split(';')[0].trim();
+ return DOWNLOAD_TYPES.has(t) ? t : 'application/octet-stream';
+}
+/** A file name safe for Content-Disposition (ASCII fallback plus RFC 5987 filename*). */
+function dispositionFor(name) {
+ const clean = String(name || 'attachment').replace(/[\r\n"\\/]/g, '_').slice(0, 180) || 'attachment';
+ const ascii = clean.replace(/[^\x20-\x7e]/g, '_');
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean)}`;
+}
+
+module.exports = { cleanHtml, downloadType, dispositionFor, MAX_HTML_BYTES, STYLE_PROPS };
@@ -267,6 +267,9 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
@@ -267,6 +267,9 @@ function createApi({ db, config, stalwart, events, principal, log = console }) {
return res.json({ messages });
return res.json({ messages });
}));
}));
+ // Reading your own mail (M1.5): server/http/reading-routes.js.
+ require('./reading-routes').mountReading(router, { db, stalwart, principal, wrap, fail, ownerOf });
+
return router;
return router;
}
}
@@ -106,6 +106,47 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -106,6 +106,47 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
}
}
+/**
+ * The inbox (M1.5): the shell public/js/inbox.js fills from /api/v1/me/mail/*. Its policy differs from the home page's
+ * in one way: a message's frame inherits it, so img-src allows https images, which the frame's own policy keeps off
+ * until the person asks to show them (public/js/inbox.js frameFor).
+ */
+const INBOX_CSP = CSP.replace("img-src 'self' data: https://openvibe.network https://openvibe.media", "img-src 'self' data: https:");
+
+function inboxPage(config) {
+ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
+ const nav = {
+ service: SERVICE,
+ apiBase: NETWORK_URL,
+ links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox', active: true }],
+ history: { type: 'page', title: `Inbox · ${SITE_NAME}` },
+ silentLogin: `${base}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ };
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: `Inbox · ${SITE_NAME}`,
+ siteName: SITE_NAME,
+ description: 'The mail that came to your mailbox, your masks and your agents.',
+ canonical: `${base}/inbox`,
+ robots: 'noindex, nofollow',
+ home: '/',
+ navLinks: nav.links,
+ navbar: nav,
+ head: `<meta name="referrer" content="no-referrer">
+<link rel="stylesheet" href="${ovServe.url('kit.css')}">
+<link rel="stylesheet" href="${asset('css/inbox.css')}">
+<script src="${asset('js/inbox.js')}" defer></script>`,
+ body: `<div id="navbar-mount"></div>
+<main data-mail-inbox id="main"><section data-mail-addresses aria-label="Your addresses"></section><section data-mail-list aria-label="Messages"></section><section data-mail-read aria-label="The message"></section></main>
+<script type="application/json" id="mail-data">{}</script>
+<script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};</script>`,
+ });
+}
+
/** Mount the page, its files and Shared's. Before the API's 404 handler. */
/** Mount the page, its files and Shared's. Before the API's 404 handler. */
function mountPages(app, { config }) {
function mountPages(app, { config }) {
app.use('/shared', ovServe.handler());
app.use('/shared', ovServe.handler());
@@ -117,6 +158,15 @@ function mountPages(app, { config }) {
@@ -117,6 +158,15 @@ function mountPages(app, { config }) {
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false }));
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('/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'));
app.use(['/css', '/js'], (_req, res) => res.status(404).set('Cache-Control', 'no-store').type('text/plain').send('Not found'));
+ let inbox = null;
+ app.get(['/inbox', '/inbox/:id'], (req, res) => {
+ // A person's own mail: signed in, or sent to sign in and back here. The page is private and never cached.
+ if (!(req.user && req.user.subject_id)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl || '/inbox')}`);
+ inbox = inbox || inboxPage(config);
+ res.set('Content-Security-Policy', INBOX_CSP);
+ res.set('Cache-Control', 'private, no-store');
+ res.type('html').send(inbox);
+ });
let html = null;
let html = null;
app.get('/', (req, res) => {
app.get('/', (req, res) => {
html = html || homePage(config);
html = html || homePage(config);
@@ -126,4 +176,4 @@ function mountPages(app, { config }) {
@@ -126,4 +176,4 @@ function mountPages(app, { config }) {
});
});
}
}
-module.exports = { mountPages, homePage, siteIdentity, CSP, asset };
+module.exports = { mountPages, homePage, inboxPage, siteIdentity, CSP, INBOX_CSP, asset };
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+/**
+ * Reading your own mail (M1.5): the person's mailbox and the addresses that deliver into it (masks, agent inboxes),
+ * for its owner only. Another person's message is the same 404 as a missing one. Bodies go to the owner and nowhere
+ * else: no log line, event or row carries one (test/reading.test.js captures the logger to prove it).
+ *
+ * GET /api/v1/me/mail/addresses the mailbox and the addresses that deliver into it
+ * GET /api/v1/me/mail/messages?address=&cursor= a page, newest first (address: one of yours, or all)
+ * GET /api/v1/me/mail/messages/:id[?images=1] one message, its HTML cleaned (server/domain/reading.js)
+ * GET /api/v1/me/mail/messages/:id/attachments/:part the bytes, as a download
+ * POST /api/v1/me/mail/messages/:id/read | /unread
+ * DELETE /api/v1/me/mail/messages/:id to the Trash
+ */
+const { cleanHtml, downloadType, dispositionFor } = require('../domain/reading');
+const masks = require('../domain/masks');
+const agentInboxes = require('../domain/agent-inboxes');
+const { mailboxAddressFor } = require('../domain/mailboxes');
+
+const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
+const PART_RE = /^[A-Za-z0-9._-]{1,64}$/;
+
+function mountReading(router, { db, stalwart, principal, wrap, fail, ownerOf }) {
+ const requirePerson = principal.requirePerson;
+ const read = requirePerson(principal.CAPABILITIES.read);
+ const write = requirePerson(principal.CAPABILITIES.write);
+ const noStore = (res) => res.set('Cache-Control', 'no-store');
+
+ /** The person's mailbox address and every address that delivers into it. */
+ async function addressesOf(owner) {
+ const [mailbox, maskRows, inboxes] = await Promise.all([
+ mailboxAddressFor(db, owner),
+ masks.listMaskRows({ db, owner }),
+ agentInboxes.listAgentInboxes({ db, owner }),
+ ]);
+ const list = [];
+ if (mailbox) list.push({ address: mailbox, kind: 'mailbox', label: null });
+ for (const m of maskRows) list.push({ address: m.address, kind: 'mask', label: m.label || null, enabled: m.enabled !== false });
+ for (const i of inboxes) list.push({ address: i.address, kind: 'agent', label: i.agent_id || null });
+ return { mailbox, list };
+ }
+
+ router.get('/api/v1/me/mail/addresses', read, wrap(async (req, res) => {
+ const { mailbox, list } = await addressesOf(ownerOf(req));
+ return noStore(res).json({ mailbox, addresses: list });
+ }));
+
+ router.get('/api/v1/me/mail/messages', read, wrap(async (req, res) => {
+ const owner = ownerOf(req);
+ const { mailbox, list } = await addressesOf(owner);
+ if (!mailbox) return noStore(res).json({ messages: [], next_cursor: null, mailbox: null });
+ const asked = String(req.query.address || 'all').trim().toLowerCase();
+ let recipient = null;
+ if (asked !== 'all' && asked !== mailbox.toLowerCase()) {
+ if (!list.some((a) => a.address.toLowerCase() === asked)) return fail(req, res, 404, 'mail.not_found', 'That address is not one of yours.');
+ recipient = asked;
+ }
+ const cursor = /^\d{1,7}$/.test(String(req.query.cursor || '')) ? Number(req.query.cursor) : 0;
+ const page = await stalwart.readMessages({ mailboxAddress: mailbox, recipient, position: cursor, limit: 50 });
+ return noStore(res).json({
+ messages: page.messages.map((m) => ({
+ id: m.id, from: m.from, to: m.to, subject: m.subject, received_at: m.receivedAt,
+ unread: m.unread, has_attachments: m.hasAttachment, spam: m.spam,
+ })),
+ next_cursor: page.next == null ? null : String(page.next),
+ });
+ }));
+
+ /** The person's mailbox, or an answer that says there is none. */
+ async function mailboxOr404(req, res) {
+ if (!ID_RE.test(String(req.params.id || ''))) { fail(req, res, 404, 'mail.not_found', 'That message is not here.'); return null; }
+ const mailbox = await mailboxAddressFor(db, ownerOf(req));
+ if (!mailbox) { fail(req, res, 404, 'mail.not_found', 'That message is not here.'); return null; }
+ return mailbox;
+ }
+
+ router.get('/api/v1/me/mail/messages/:id', read, wrap(async (req, res) => {
+ const mailbox = await mailboxOr404(req, res);
+ if (!mailbox) return undefined;
+ const m = await stalwart.readMessage({ mailboxAddress: mailbox, id: req.params.id });
+ if (!m) return fail(req, res, 404, 'mail.not_found', 'That message is not here.');
+ const images = String(req.query.images || '') === '1';
+ const cleaned = m.html ? cleanHtml(m.html, { images }) : { html: '', blockedImages: 0 };
+ return noStore(res).json({
+ message: {
+ id: m.id, from: m.from, to: m.to, cc: m.cc, subject: m.subject, date: m.date, unread: m.unread,
+ text: m.text || '', html: cleaned.html || null, blocked_images: cleaned.blockedImages,
+ attachments: m.attachments.map((a) => ({ part: a.part, name: a.name, type: downloadType(a.type), size: a.size })),
+ },
+ });
+ }));
+
+ router.get('/api/v1/me/mail/messages/:id/attachments/:part', read, wrap(async (req, res) => {
+ const mailbox = await mailboxOr404(req, res);
+ if (!mailbox) return undefined;
+ if (!PART_RE.test(String(req.params.part || ''))) return fail(req, res, 404, 'mail.not_found', 'That attachment is not here.');
+ let file;
+ try {
+ file = await stalwart.readAttachment({ mailboxAddress: mailbox, id: req.params.id, part: req.params.part });
+ } catch (err) {
+ if (err && err.status === 413) return fail(req, res, 413, 'mail.attachment_too_large', 'That attachment is too large to download here.');
+ throw err;
+ }
+ if (!file) return fail(req, res, 404, 'mail.not_found', 'That attachment is not here.');
+ noStore(res);
+ res.set('Content-Type', downloadType(file.type));
+ res.set('Content-Disposition', dispositionFor(file.name));
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.set('Content-Security-Policy', "default-src 'none'; sandbox");
+ return res.end(file.bytes);
+ }));
+
+ for (const [path, seen] of [['read', true], ['unread', false]]) {
+ router.post(`/api/v1/me/mail/messages/:id/${path}`, write, wrap(async (req, res) => {
+ const mailbox = await mailboxOr404(req, res);
+ if (!mailbox) return undefined;
+ const ok = await stalwart.setSeen({ mailboxAddress: mailbox, id: req.params.id, seen });
+ if (!ok) return fail(req, res, 404, 'mail.not_found', 'That message is not here.');
+ return noStore(res).json({ id: req.params.id, unread: !seen });
+ }));
+ }
+
+ router.delete('/api/v1/me/mail/messages/:id', write, wrap(async (req, res) => {
+ const mailbox = await mailboxOr404(req, res);
+ if (!mailbox) return undefined;
+ const ok = await stalwart.trashMessage({ mailboxAddress: mailbox, id: req.params.id });
+ if (!ok) return fail(req, res, 404, 'mail.not_found', 'That message is not here.');
+ return noStore(res).json({ id: req.params.id, trashed: true });
+ }));
+}
+
+module.exports = { mountReading };
@@ -296,11 +296,128 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -296,11 +296,128 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
return got.list || [];
return got.list || [];
}
}
+ // ---- reading your own mail (M1.5): the owner's page asks, the answer goes to them and is not kept ----
+
+ const READ_LIST_PROPS = ['id', 'from', 'to', 'subject', 'receivedAt', 'keywords', 'hasAttachment', 'headers'];
+
+ /**
+ * A page of the person's mailbox, newest first. `recipient` keeps the messages the server stamped for one of their
+ * addresses (a mask or an agent inbox); without it, every message in the mailbox. `position` is where the last page
+ * stopped in the mailbox's own order. Answers { messages, next } (next is null at the end).
+ */
+ async function readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {
+ const accountId = await accountIdFor(mailboxAddress);
+ if (!accountId) return { messages: [], next: null };
+ const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
+ const perPage = Math.min(Math.max(Number(limit) || 50, 1), 100);
+ const scan = wanted ? 500 : perPage;
+ const start = Math.max(0, Math.floor(Number(position) || 0));
+ const queried = await one('Email/query', { accountId, filter: {}, sort: [{ property: 'receivedAt', isAscending: false }], position: start, limit: scan }, 'q');
+ const ids = queried.ids || [];
+ if (!ids.length) return { messages: [], next: null };
+ const got = await one('Email/get', { accountId, ids, properties: READ_LIST_PROPS }, 'e');
+ const byId = new Map((got.list || []).map((m) => [m.id, m]));
+ const messages = [];
+ let used = 0;
+ for (const id of ids) {
+ used += 1;
+ const m = byId.get(id);
+ if (!m) continue;
+ const to = stampedRecipient(m);
+ if (wanted && to !== wanted) continue;
+ messages.push({
+ id: m.id,
+ from: (m.from || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean),
+ to: to ? [to] : (m.to || []).map((a) => a.email).filter(Boolean).slice(0, 1),
+ subject: m.subject || null,
+ receivedAt: m.receivedAt || null,
+ unread: !(m.keywords && m.keywords.$seen),
+ hasAttachment: Boolean(m.hasAttachment),
+ spam: Boolean(m.keywords && m.keywords.$junk),
+ });
+ if (messages.length >= perPage) break;
+ }
+ const more = used < ids.length || ids.length === scan;
+ return { messages, next: more ? start + used : null };
+ }
+
+ /**
+ * One message for its owner to read: headers that matter, the text and HTML bodies (each at most 1 MB, raw: the
+ * caller cleans the HTML), and the attachments (part id, name, type, size). Null when it is not in that mailbox.
+ */
+ async function readMessage({ mailboxAddress, id }) {
+ const accountId = await accountIdFor(mailboxAddress);
+ if (!accountId) return null;
+ const got = await one('Email/get', {
+ accountId, ids: [id],
+ properties: ['id', 'from', 'to', 'cc', 'subject', 'receivedAt', 'sentAt', 'keywords', 'textBody', 'htmlBody', 'attachments', 'bodyValues'],
+ fetchTextBodyValues: true, fetchHTMLBodyValues: true, maxBodyValueBytes: 1024 * 1024,
+ }, 'e');
+ const m = (got.list || [])[0];
+ if (!m) return null;
+ const values = m.bodyValues || {};
+ const join = (parts, html) => (parts || [])
+ .filter((p) => p && values[p.partId] && (html ? String(p.type || '').toLowerCase() === 'text/html' : String(p.type || '').toLowerCase() !== 'text/html'))
+ .map((p) => values[p.partId].value).join('\n');
+ const addr = (list) => (list || []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).filter(Boolean);
+ return {
+ id: m.id,
+ from: addr(m.from), to: addr(m.to), cc: addr(m.cc),
+ subject: m.subject || null,
+ date: m.sentAt || m.receivedAt || null,
+ unread: !(m.keywords && m.keywords.$seen),
+ text: join(m.textBody, false),
+ html: join(m.htmlBody, true),
+ attachments: (m.attachments || []).filter((a) => a && a.blobId).map((a) => ({ part: String(a.partId || a.blobId), blobId: a.blobId, name: a.name || null, type: a.type || null, size: Number(a.size) || 0 })),
+ };
+ }
+
+ /** An attachment's bytes, when that part belongs to that message in that mailbox; null otherwise. */
+ async function readAttachment({ mailboxAddress, id, part, maxBytes = 25 * 1024 * 1024 }) {
+ const message = await readMessage({ mailboxAddress, id });
+ if (!message) return null;
+ const att = message.attachments.find((a) => a.part === String(part));
+ if (!att) return null;
+ if (att.size > maxBytes) throw new StalwartError('the attachment is too large to download here', { status: 413, code: 'mail.attachment_too_large' });
+ const accountId = await accountIdFor(mailboxAddress);
+ const blobs = await one('Blob/get', { accountId, ids: [att.blobId], properties: ['data:asBase64', 'size'] }, 'b');
+ const blob = (blobs.list || [])[0];
+ if (!blob || typeof blob['data:asBase64'] !== 'string') return null;
+ return { name: att.name, type: att.type, bytes: Buffer.from(blob['data:asBase64'], 'base64') };
+ }
+
+ /** Read or unread, on the person's own message. */
+ async function setSeen({ mailboxAddress, id, seen }) {
+ const accountId = await accountIdFor(mailboxAddress);
+ if (!accountId) return false;
+ const result = await one('Email/set', { accountId, update: { [id]: { 'keywords/$seen': seen ? true : null } } }, 's');
+ return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
+ }
+
+ /** Move a message to the mailbox's Trash (Stalwart expunges it after its retention); false when it is not there. */
+ async function trashMessage({ mailboxAddress, id }) {
+ const accountId = await accountIdFor(mailboxAddress);
+ if (!accountId) return false;
+ const boxes = await one('Mailbox/query', { accountId, filter: { role: 'trash' } }, 'm');
+ const trash = (boxes.ids || [])[0];
+ if (!trash) {
+ const result = await one('Email/set', { accountId, destroy: [id] }, 's');
+ return (result.destroyed || []).includes(id);
+ }
+ const result = await one('Email/set', { accountId, update: { [id]: { mailboxIds: { [trash]: true } } } }, 's');
+ return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
+ }
+
return {
return {
jmap,
jmap,
one,
one,
accountIdFor,
accountIdFor,
listMessages,
listMessages,
+ readMessages,
+ readMessage,
+ readAttachment,
+ setSeen,
+ trashMessage,
deleteMessages,
deleteMessages,
destroyAccount,
destroyAccount,
messageBody,
messageBody,
@@ -34,8 +34,8 @@
@@ -34,8 +34,8 @@
<h2 id="ml-more-h" class="ml-sr">What else it does</h2>
<h2 id="ml-more-h" class="ml-sr">What else it does</h2>
<div class="ml-grid">
<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>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>
+<article class="ml-card"><h3>Masks</h3><p>An address per site that delivers to your inbox, so a leak or a spammer never learns your real one. One switch turns it off.</p><p><a class="ml-card-link" href="/inbox">Make one in your inbox</a></p></article>
+<article class="ml-card"><h3>Your inbox</h3><p>Read what came to your masks and your agents' inboxes, with trackers off until you say. Sending and your own domain come next.</p><p><a class="ml-card-link" href="/inbox">Open your inbox</a></p></article>
</div>
</div>
</section>
</section>
@@ -23,7 +23,7 @@ function createFakeStalwart() {
@@ -23,7 +23,7 @@ function createFakeStalwart() {
mailboxes,
mailboxes,
/** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */
/** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */
- deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [] }) {
+ deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [], attachments = [] }) {
counter += 1;
counter += 1;
const message = {
const message = {
id: `m${counter}`,
id: `m${counter}`,
@@ -37,6 +37,7 @@ function createFakeStalwart() {
@@ -37,6 +37,7 @@ function createFakeStalwart() {
{ name: 'Delivered-To', value: ` ${String(to).toLowerCase()}` },
{ name: 'Delivered-To', value: ` ${String(to).toLowerCase()}` },
...headers,
...headers,
],
],
+ attachments,
};
};
mailbox(mailboxAddress).push(message);
mailbox(mailboxAddress).push(message);
return message;
return message;
@@ -80,6 +81,45 @@ function createFakeStalwart() {
@@ -80,6 +81,45 @@ function createFakeStalwart() {
return { text: message.text, html: message.html };
return { text: message.text, html: message.html };
},
},
+ // ---- reading (M1.5): the same rules as the real client (server/stalwart/client.js) ----
+ async readMessages({ mailboxAddress, recipient = null, position = 0, limit = 50 }) {
+ const all = [...(mailboxes.get(String(mailboxAddress || '').toLowerCase()) || [])].reverse();
+ const wanted = recipient ? String(recipient).trim().toLowerCase() : null;
+ const stamped = (m) => { const f = (m.headers || []).find((h) => String(h && h.name || '').toLowerCase() === 'delivered-to'); return f ? String(f.value || '').trim().toLowerCase() : null; };
+ const picked = [];
+ let used = 0;
+ for (const m of all.slice(position)) {
+ used += 1;
+ if (wanted && stamped(m) !== wanted) continue;
+ picked.push({ id: m.id, from: m.from.map((a) => a.email), to: [stamped(m)], subject: m.subject, receivedAt: m.receivedAt, unread: !m.seen, hasAttachment: Boolean((m.attachments || []).length), spam: false });
+ if (picked.length >= limit) break;
+ }
+ return { messages: picked, next: position + used < all.length ? position + used : null };
+ },
+ async readMessage({ mailboxAddress, id }) {
+ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
+ if (!m) return null;
+ return { id: m.id, from: m.from.map((a) => a.email), to: m.to.map((a) => a.email), cc: [], subject: m.subject, date: m.receivedAt, unread: !m.seen, text: m.text || '', html: m.html || '',
+ attachments: (m.attachments || []).map((a) => ({ part: a.part, blobId: `b-${a.part}`, name: a.name, type: a.type, size: a.bytes.length })) };
+ },
+ async readAttachment({ mailboxAddress, id, part }) {
+ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
+ const a = m && (m.attachments || []).find((x) => x.part === String(part));
+ return a ? { name: a.name, type: a.type, bytes: a.bytes } : null;
+ },
+ async setSeen({ mailboxAddress, id, seen }) {
+ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
+ if (!m) return false;
+ m.seen = Boolean(seen);
+ return true;
+ },
+ async trashMessage({ mailboxAddress, id }) {
+ const m = (mailboxes.get(String(mailboxAddress || '').toLowerCase()) || []).find((x) => x.id === id && !x.trashed);
+ if (!m) return false;
+ m.trashed = true;
+ return true;
+ },
+
async deleteMessages({ mailboxAddress, ids }) {
async deleteMessages({ mailboxAddress, ids }) {
calls.deleteMessages += 1;
calls.deleteMessages += 1;
const key = String(mailboxAddress || '').toLowerCase();
const key = String(mailboxAddress || '').toLowerCase();
@@ -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', '/release.json'].sort());
+ assert.deepStrictEqual(proxied, ['/', '^/(css|js|shared)/', '^/inbox(/[A-Za-z0-9_-]+)?$', '/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,
@@ -24,8 +24,10 @@ test('/ is the page: the ten-minute address, the honest cards, the navbar, a str
@@ -24,8 +24,10 @@ test('/ is the page: the ten-minute address, the honest cards, the navbar, a str
for (const marker of ['data-mail-burner', 'Get a 10-minute address', 'data-mail-mine', 'Inboxes for your agents', 'navbar-mount', '/shared/']) {
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);
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);
+ // Masks and the inbox work since M1.5: no card says Later, and both lead to /inbox; sending is said to come next.
+ assert.strictEqual((r.text.match(/class="ml-soon">Later</g) || []).length, 0);
+ assert.strictEqual((r.text.match(/href="\/inbox"/g) || []).length, 2);
+ assert.match(r.text, /Sending and your own domain come next/);
assert.doesNotMatch(r.text, /<form[^>]*action="\/api/, 'nothing posts to the API without the script');
assert.doesNotMatch(r.text, /<form[^>]*action="\/api/, 'nothing posts to the API without the script');
const csp = r.headers.get('content-security-policy') || '';
const csp = r.headers.get('content-security-policy') || '';
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /frame-ancestors 'none'/);
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * Reading your own mail (M1.5). The rules these tests hold: a person reads their mailbox and the addresses that
+ * deliver into it, and nothing else; a message's HTML is cleaned on the server (no script, handler, form, frame or
+ * javascript: link) with remote images held back and counted until asked; an attachment is a download with nosniff;
+ * read, unread and trash work on one's own message only; no body ever reaches a log line.
+ */
+const test = require('node:test');
+const assert = require('node:assert');
+const { boot } = require('./helpers/boot');
+const { cleanHtml, downloadType, dispositionFor } = require('../server/domain/reading');
+
+async function mailboxOf(s, person) {
+ // A person's mailbox is made with their first address (a mask here).
+ const made = await s.post('/api/v1/masks', { as: person, json: { label: 'shoes shop' } });
+ assert.strictEqual(made.status, 201, JSON.stringify(made.body));
+ const mailbox = await s.db.value('SELECT address FROM mail_mailboxes WHERE owner_subject = $1', [person.subject]);
+ return { mailbox, mask: made.body.mask };
+}
+
+test('the cleaner keeps the letter and drops everything that could act', () => {
+ const { html, blockedImages } = cleanHtml(`<p onclick="x()">Hi<script>steal()</script></p>
+<a href="javascript:alert(1)">j</a><a href="https://shop.example/track">Track</a><a href="data:text/html,x">d</a>
+<form action="https://x"><input name="p"></form><iframe src="https://x"></iframe><style>body{}</style><base href="https://evil">
+<div style="background:url(https://t.example/x);color:red;position:fixed">s</div><p style="color: expression(alert(1))">e</p>
+<img src="https://t.example/pixel.gif" alt="logo"><img src="//t.example/r.gif"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">`);
+ for (const bad of ['script', 'onclick', 'javascript:', '<form', '<input', '<iframe', '<style', '<base', 'url(', 'expression', 'position', 'data:text/html']) {
+ assert.ok(!html.includes(bad), `no ${bad}: ${html}`);
+ }
+ assert.match(html, /<a href="https:\/\/shop\.example\/track" target="_blank" rel="noopener noreferrer">Track<\/a>/);
+ assert.match(html, /color:red/);
+ assert.ok(!html.includes('t.example/pixel.gif'), 'a remote image is held back');
+ assert.match(html, /\[logo\]/, 'its alt text stays in its place');
+ assert.match(html, /data:image\/gif/, 'an image inside the message stays');
+ assert.strictEqual(blockedImages, 2);
+ const shown = cleanHtml('<img src="https://t.example/pixel.gif" alt="logo"><img src="http://t.example/q.gif">', { images: true });
+ assert.match(shown.html, /src="https:\/\/t\.example\/pixel\.gif"/, 'asked for: an https image is shown');
+ assert.ok(!shown.html.includes('http://t.example'), 'never a plain-http one');
+});
+
+test('attachment types are an allow-list, names are safe in a header', () => {
+ assert.strictEqual(downloadType('application/pdf'), 'application/pdf');
+ assert.strictEqual(downloadType('text/html'), 'application/octet-stream');
+ assert.strictEqual(downloadType('image/svg+xml'), 'application/octet-stream');
+ assert.ok(!/[\r\n]/.test(dispositionFor('a\r\nSet-Cookie: x.pdf')));
+ assert.match(dispositionFor('café.pdf'), /^attachment; filename="caf_\.pdf"; filename\*=UTF-8''caf%C3%A9\.pdf$/);
+});
+
+test('a person reads their mailbox, one address at a time or all together', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const { mailbox, mask } = await mailboxOf(s, alice);
+ s.mail.deliver({ mailboxAddress: mailbox, to: mask.address, from: '[email protected]', subject: 'Shipped', html: '<p>On its way</p>' });
+ s.mail.deliver({ mailboxAddress: mailbox, to: mailbox, from: '[email protected]', subject: 'Friday film?', text: 'Popcorn?' });
+
+ const addrs = await s.get('/api/v1/me/mail/addresses', { as: alice });
+ assert.strictEqual(addrs.status, 200);
+ assert.strictEqual(addrs.body.mailbox, mailbox);
+ assert.deepStrictEqual(addrs.body.addresses.map((a) => a.kind).sort(), ['mailbox', 'mask']);
+
+ const all = await s.get('/api/v1/me/mail/messages', { as: alice });
+ assert.strictEqual(all.status, 200);
+ assert.deepStrictEqual(all.body.messages.map((m) => m.subject), ['Friday film?', 'Shipped'], 'newest first');
+ assert.strictEqual(all.body.messages[0].unread, true);
+ assert.strictEqual(all.body.messages[0].text, undefined, 'a list carries no body');
+
+ const one = await s.get(`/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: alice });
+ assert.deepStrictEqual(one.body.messages.map((m) => m.subject), ['Shipped'], 'the mask: only what the server stamped for it');
+ const notMine = await s.get('/api/v1/me/mail/[email protected]', { as: alice });
+ assert.strictEqual(notMine.status, 404);
+});
+
+test('a message is read by its owner only, its HTML cleaned, and it can be marked and trashed', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const bob = s.network.addUser('bob');
+ const { mailbox, mask } = await mailboxOf(s, alice);
+ await mailboxOf(s, bob);
+ const m = s.mail.deliver({ mailboxAddress: mailbox, to: mask.address, subject: 'Shipped',
+ html: '<p onclick="x()">On its way<script>1</script></p><img src="https://t.example/p.gif" alt="logo">',
+ attachments: [{ part: '2', name: 'invoice.pdf', type: 'application/pdf', bytes: Buffer.from('%PDF-1.4 test') },
+ { part: '3', name: 'page.html', type: 'text/html', bytes: Buffer.from('<script>1</script>') }] });
+
+ const read = await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: alice });
+ assert.strictEqual(read.status, 200);
+ assert.match(read.headers.get('cache-control') || '', /no-store/);
+ assert.ok(!/script|onclick|t\.example\/p\.gif/.test(read.body.message.html), read.body.message.html);
+ assert.strictEqual(read.body.message.blocked_images, 1);
+ assert.deepStrictEqual(read.body.message.attachments.map((a) => [a.name, a.type]), [['invoice.pdf', 'application/pdf'], ['page.html', 'application/octet-stream']]);
+ const shown = await s.get(`/api/v1/me/mail/messages/${m.id}?images=1`, { as: alice });
+ assert.match(shown.body.message.html, /t\.example\/p\.gif/);
+
+ assert.strictEqual((await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: bob })).status, 404, 'another person: not found');
+
+ const file = await s.get(`/api/v1/me/mail/messages/${m.id}/attachments/3`, { as: alice });
+ assert.strictEqual(file.status, 200);
+ assert.strictEqual(file.headers.get('content-type'), 'application/octet-stream', 'html never served as html');
+ assert.match(file.headers.get('content-disposition') || '', /^attachment;/);
+ assert.strictEqual(file.headers.get('x-content-type-options'), 'nosniff');
+ assert.strictEqual((await s.get(`/api/v1/me/mail/messages/${m.id}/attachments/2`, { as: bob })).status, 404);
+
+ assert.strictEqual((await s.post(`/api/v1/me/mail/messages/${m.id}/read`, { as: alice })).status, 200);
+ let list = await s.get('/api/v1/me/mail/messages', { as: alice });
+ assert.strictEqual(list.body.messages[0].unread, false);
+ assert.strictEqual((await s.post(`/api/v1/me/mail/messages/${m.id}/unread`, { as: alice })).status, 200);
+ assert.strictEqual((await s.post(`/api/v1/me/mail/messages/${m.id}/read`, { as: bob })).status, 404);
+
+ assert.strictEqual((await s.del(`/api/v1/me/mail/messages/${m.id}`, { as: bob })).status, 404);
+ assert.strictEqual((await s.del(`/api/v1/me/mail/messages/${m.id}`, { as: alice })).status, 200);
+ assert.strictEqual((await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: alice })).status, 404, 'trashed: gone from the inbox');
+});
+
+test('signed out, the inbox sends you to sign in; signed in it is private and never cached', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const out = await fetch(`${s.base}/inbox`, { redirect: 'manual' });
+ assert.strictEqual(out.status, 302);
+ assert.match(out.headers.get('location') || '', /^\/auth\/login\?next=%2Finbox/);
+ const unauth = await s.get('/api/v1/me/mail/messages');
+ assert.strictEqual(unauth.status, 401);
+});
+
+test('no message body reaches a log line while reading', async (t) => {
+ const s = await boot();
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const { mailbox, mask } = await mailboxOf(s, alice);
+ const m = s.mail.deliver({ mailboxAddress: mailbox, to: mask.address, subject: 'Secret plans', text: 'the-body-marker-7731', html: '<p>the-html-marker-7731</p>' });
+ await s.get(`/api/v1/me/mail/messages/${m.id}`, { as: alice });
+ await s.get('/api/v1/me/mail/messages', { as: alice });
+ assert.ok(!s.logs().some((l) => /marker-7731/.test(l)), 'a body in a log line');
+});
@@ -393,3 +393,38 @@ test('a ten-minute burner works without an account, read with its token', async
@@ -393,3 +393,38 @@ test('a ten-minute burner works without an account, read with its token', async
const without = await call('GET', `/api/v1/burners/${made.body.burner.id}/code`);
const without = await call('GET', `/api/v1/burners/${made.body.burner.id}/code`);
assert.strictEqual(without.status, 404, 'the burner is not readable without its token');
assert.strictEqual(without.status, 404, 'the burner is not readable without its token');
});
});
+
+test('reading your own mail (M1.5): a mask\'s HTML arrives cleaned, and it is marked and trashed', async () => {
+ const reader = network.addUser('reader');
+ const other = network.addUser('other-reader');
+ const mask = (await call('POST', '/api/v1/masks', { as: reader, json: { label: 'shoes' } })).body.mask;
+ await call('POST', '/api/v1/masks', { as: other, json: { label: 'other' } });
+ await send({
+ port: stalwart.smtpPort, to: mask.address, subject: 'Shipped',
+ html: '<p onclick="x()">On its way<script>steal()</script></p><img src="https://t.example/pixel.gif" alt="logo"><a href="javascript:alert(1)">x</a>',
+ });
+ let list = null;
+ for (let i = 0; i < 40; i++) {
+ list = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });
+ if (list.status === 200 && list.body.messages.length) break;
+ await sleep(250);
+ }
+ assert.strictEqual(list.status, 200, JSON.stringify(list.body));
+ assert.strictEqual(list.body.messages.length, 1, 'the mask has its message');
+ const m = list.body.messages[0];
+ assert.strictEqual(m.subject, 'Shipped');
+ assert.strictEqual(m.unread, true);
+
+ const read = await call('GET', `/api/v1/me/mail/messages/${m.id}`, { as: reader });
+ assert.strictEqual(read.status, 200, JSON.stringify(read.body));
+ assert.ok(read.body.message.html && read.body.message.html.includes('On its way'), 'the html body came back from JMAP');
+ assert.ok(!/script|onclick|javascript:|pixel\.gif/.test(read.body.message.html), read.body.message.html);
+ assert.strictEqual(read.body.message.blocked_images, 1);
+ assert.strictEqual((await call('GET', `/api/v1/me/mail/messages/${m.id}`, { as: other })).status, 404, 'another person');
+
+ assert.strictEqual((await call('POST', `/api/v1/me/mail/messages/${m.id}/read`, { as: reader })).status, 200);
+ const after = await call('GET', `/api/v1/me/mail/messages?address=${encodeURIComponent(mask.address)}`, { as: reader });
+ assert.strictEqual(after.body.messages[0].unread, false, 'seen on the server');
+ assert.strictEqual((await call('DELETE', `/api/v1/me/mail/messages/${m.id}`, { as: reader })).status, 200);
+ assert.ok(!logs.some((l) => /On its way|steal\(\)/.test(String(l))), 'no body in the log');
+});