openvibers/openvibe.mail README.md
README.md
| 1 | # OpenVibe.Mail |
| 2 | |
| 3 | `openvibe.email` — masks that protect you, burners that self-destruct, and an inbox for every agent. |
| 4 | |
| 5 | **Live site:** https://openvibe.email · **API docs:** https://openvibe.services · **For AI:** https://openvibe.email/llms.txt |
| 6 | **Run it:** `npm ci && npm start` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | Mail receives mail for OpenVibe mailboxes and includes opt-in Inbox AI, which can also pull another provider's |
| 9 | mailbox over IMAP (TLS only, read only, app password sealed with `MAIL_SECRETS_KEY`). Replies stay drafts until the |
| 10 | person edits and clicks Send; external delivery remains closed unless the configured outbound relay is enabled. See |
| 11 | [Inbox AI](docs/INBOX-AI.md) for the consent, rules, journal, undo and receipts APIs. |
| 12 | |
| 13 | ## Person mail |
| 14 | |
| 15 | `/inbox` also lets a person create a named address or alias, compose plain-text mail, attach a custom domain, and |
| 16 | forward a copy to another email they verified on their Network account. Sending requires a verified account email, |
| 17 | a one-day-old account, a verified sender domain, and a live Network trust check. The allowance starts at 20 |
| 18 | recipients per day and rises to 50 or 100 with account age and successful history. Each message has at most 10 |
| 19 | recipients. Hard bounces and complaints tied to provider message IDs pause the sender; a report link is in the |
| 20 | message and its `X-OpenVibe-Report-Abuse` header for SMTP delivery. No bulk list mode is offered. |
| 21 | |
| 22 | Forwarding checks Network ownership when a rule is created and again before each copy. A revoked address disables |
| 23 | its rule. Only non-junk plain-text messages without attachments are copied; an ambiguous provider response is not |
| 24 | retried automatically. Network service credentials and the `identity.trust.read` and `identity.emails.read` grants |
| 25 | must be present. `MAIL_OUTBOUND=on` and an enabled provider remain required for external sending and forwarding. |
| 26 | See [custom-domain DNS](docs/DNS.md) for the inbound path and verification states. |
| 27 | |
| 28 | ## M1.5: read your mail (2026-11-07) |
| 29 | |
| 30 | `/inbox` on openvibe.email shows a signed-in person's mailbox and the addresses that deliver into it (masks, agent |
| 31 | inboxes), for its owner only (another person's message is the same 404 as a missing one): |
| 32 | |
| 33 | | Route | What | |
| 34 | |---|---| |
| 35 | | `GET /api/v1/me/mail/addresses` | the mailbox and the addresses that deliver into it | |
| 36 | | `GET /api/v1/me/mail/messages?address=&cursor=` | a page, newest first; an address is matched on the server's `Delivered-To` stamp only | |
| 37 | | `GET /api/v1/me/mail/messages/:id[?images=1]` | one message: text, and HTML cleaned on the server | |
| 38 | | `GET /api/v1/me/mail/messages/:id/attachments/:part` | a download (`attachment`, `nosniff`, an allow-listed type or `application/octet-stream`) | |
| 39 | | `POST …/:id/read`, `…/:id/unread`, `DELETE …/:id` | seen flags; Delete moves it to the Trash | |
| 40 | |
| 41 | **Two layers for a sender's HTML.** `server/domain/reading.js` cleans it with |
| 42 | [sanitize-html](https://github.com/apostrophecms/sanitize-html) (MIT): an allow-list of tags and attributes, `style` |
| 43 | limited to layout and colour properties whose values can hold no `url()`, `expression()` or `@import`, links only |
| 44 | http(s)/mailto opening in a new tab with `rel="noopener noreferrer"`. Remote images, the way senders track opening, |
| 45 | are replaced by their alt text and counted (`blocked_images`) until the person asks (`?images=1`, https only). The |
| 46 | page (`public/js/inbox.js`) then shows the result in an iframe with no `allow-scripts` and its own CSP |
| 47 | (`default-src 'none'`). Bodies go to their owner and nowhere else: never a log line, an event or a row |
| 48 | (`test/reading.test.js` and the Stalwart acceptance test check the logger). |
| 49 | |
| 50 | ## What is in M1 |
| 51 | |
| 52 | | Piece | What it does | |
| 53 | |---|---| |
| 54 | | **Masks** | `[email protected]` — a per-site address that delivers into your OpenVibe mailbox and nowhere else. Turn it off and it bounces. | |
| 55 | | **Burners** | `[email protected]` — receive-only, expires after 10 minutes, 1 hour, 1 day or 7 days, or after the first message. A 10-minute burner needs no account. Its mail is deleted when it expires. | |
| 56 | | **Agent inboxes** | `[email protected]` — an address an agent can read, on the person's own domain. | |
| 57 | | **Directory** | Stalwart's SQL directory reads `mail_directory_accounts`: every unexpired burner is its **own** account (the burner address is the primary address), so one burner's mail can never be read in another's. Masks and agent inboxes are aliases of the owner's mailbox. A disabled mask or an expired burner resolves to no row and the mail bounces. | |
| 58 | | **Blobs** | Message blobs live on the mail server's local disk by default (`/var/lib/openvibe-stalwart/blobs`, `STALWART_BLOB_STORE=fs`). S3-compatible storage (B2 in production, MinIO in CI) stays available with `STALWART_BLOB_STORE=s3`. | |
| 59 | |
| 60 | Raw message bodies are read from Stalwart over loopback JMAP and are never written to logs or events. With Inbox AI |
| 61 | enabled, summaries and editable reply drafts are stored per thread in Mail; the draft is the only generated body |
| 62 | persisted. A burner's account is read with no |
| 63 | recipient filtering at all; a mask/agent inbox in the shared person mailbox is matched only against the first |
| 64 | `Delivered-To` header, the one Stalwart stamps — never a sender-written To, Cc or Delivered-To. |
| 65 | |
| 66 | ## API |
| 67 | |
| 68 | All routes are under `/api/v1` and take an OpenVibe.Network token (`Authorization: Bearer …`), or the person's |
| 69 | same-site session cookie on openvibe.email (set by `openvibe-sdk/sso` after sign-in; `/auth/*` is the sign-in |
| 70 | handshake). A cross-origin browser may send a Bearer token from any first-party origin in the Contracts manifests |
| 71 | (CORS); a cookie-authenticated **write** must come from openvibe.email itself (`Sec-Fetch-Site`/`Origin`). An app |
| 72 | token must name the person it acts for (`on_behalf_of`) and carry `mail.addresses.write` or |
| 73 | `mail.messages.read`. A typed token (a `typ` or `purpose` claim) is refused. |
| 74 | |
| 75 | | Method | Route | Who | |
| 76 | |---|---|---| |
| 77 | | `GET` | `/me/mail` | a person; masks, burners and agent inboxes with state, expiry and counts (see below) | |
| 78 | | `POST` | `/masks` | a person; `{ purpose, expires_days?, label? }` | |
| 79 | | `GET` | `/masks` | a person | |
| 80 | | `GET` | `/masks/:id` | the owner; anyone else gets 404 | |
| 81 | | `POST` | `/masks/:id/disable` | the owner; turns the mask off | |
| 82 | | `DELETE` | `/masks/:id` | the same, for a DELETE-shaped client | |
| 83 | | `POST` | `/burners` | a person, or nobody (`{ expires: "10m" \| "1h" \| "1d" \| "7d", first_message_only }`) | |
| 84 | | `GET` | `/burners` | a person | |
| 85 | | `GET` | `/burners/:id/messages` | the owner, or the holder of the returned `x-mail-burner-token` | |
| 86 | | `GET` | `/burners/:id/code` | the same; the newest verification code, extracted from text or HTML | |
| 87 | | `POST` | `/agent-inboxes` | a person; `{ agent_id }` | |
| 88 | | `GET` | `/agent-inboxes` | a person | |
| 89 | | `GET` | `/agent-inboxes/:id/messages` | the owner | |
| 90 | | `GET` | `/agent-inboxes/:id/answering` | the owner; the switch, off by default (`mail.inbox-answering@1`) | |
| 91 | | `PUT` | `/agent-inboxes/:id/answering` | the owner; `{ enabled }` turns an agent's answering on or off | |
| 92 | | `GET` | `/me/mail/blocks` | the person's blocked senders | |
| 93 | | `POST` | `/me/mail/blocks` | the person; `{ address }` | |
| 94 | | `DELETE` | `/me/mail/blocks/:address` | the person's own block | |
| 95 | |
| 96 | Somebody else's mask, burner or inbox answers **404**, never 403: the answer must not say it exists. The messages |
| 97 | API returns an array of `{ id, from, subject, received_at }` and nothing else: no preview, no body, no HTML. The |
| 98 | `/code` endpoint returns `{ code, message_id, received_at, from }`; it extracts the code from text or HTML in |
| 99 | memory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the liveness |
| 100 | answer. |
| 101 | |
| 102 | `GET /api/v1/me/mail` is the one call the page makes: it returns `masks`, `burners` and `agent_inboxes`, where each |
| 103 | item carries the Contracts resource (`mask`, `burner`, `agent_inbox`) exactly, beside the listing fields `address`, |
| 104 | `purpose`, `state`, a mask's `expires_at`, and `counts.messages` (the messages the service has already seen for that |
| 105 | address), plus totals under `counts`. No subject, sender, body or code is in it. A mask made with `expires_days` turns itself off when the date |
| 106 | arrives; the purge job disables the due rows and the directory stops resolving them. |
| 107 | |
| 108 | ## Events |
| 109 | |
| 110 | The outbox (`event_outbox`) publishes, through OpenVibe.Events when configured: |
| 111 | |
| 112 | - `mail.mask.created` — the mask exists; |
| 113 | - `mail.received` — metadata only: the address, the sender, the message id and the time. **No subject, no body, |
| 114 | no code.** |
| 115 | - `mail.agent_inbox.received` — an agent's address whose answering the person turned ON. The inbox and the agent, |
| 116 | the owner, the message id, the sender, the subject, and a plain-text excerpt of **at most 4000 characters**. |
| 117 | Never the HTML, never an attachment, and nothing at all for a message the mail server marked as spam or a sender |
| 118 | on the person's block list. |
| 119 | |
| 120 | ## An agent answers the mail sent to its own address (HO6) |
| 121 | |
| 122 | An agent's inbox is an address, not an invitation: answering is a **switch, off by default**, that the person turns |
| 123 | on for one agent. While it is on, the due poll (`server/jobs/agent-mail.js`) reads the newest messages of the |
| 124 | person's mailbox as that inbox, and for each letter not seen before publishes `mail.agent_inbox.received` once — |
| 125 | the row that says the letter is the agent's and the event commit in one transaction, so an overlapping poll or a |
| 126 | restart never announces it twice. Spam, a blocked sender and the inbox's own address start nothing. |
| 127 | |
| 128 | The agent's system then acts on the event. It reads the whole letter in plain text through `mail.agent.read` |
| 129 | (loopback, first-party service token, the person named in `X-OV-Subject`), and answers through `mail.agent.reply`: |
| 130 | the reply is sent **from the agent's address**, to the sender of the letter and to nobody else, plain text, with |
| 131 | `In-Reply-To`/`References` read from the letter itself. Every send is recorded in `mail_sent_mail` with its outcome. |
| 132 | |
| 133 | **Outbound is not ours yet.** A reply goes only to one of the owner's own `openvibe.email` addresses — their |
| 134 | mailbox, a mask, a burner — which is local delivery on our own server, no packet off the host. Sending to anyone |
| 135 | else is plan M2 and waits on the owner (a separate mail IPv4, its PTR record, Workers Paid for the relay), so it is |
| 136 | behind a switch that is **off by default**: `MAIL_OUTBOUND=on` is the only way on. With it off, `routeFor` answers |
| 137 | before any connection is opened, `POST …/reply` answers `409 mail.outbound_closed` with the plain sentence "OpenVibe |
| 138 | Mail cannot send outside OpenVibe yet: the reply waits here", and the agent's system turns that into the person's |
| 139 | send approval: the reply stays pending, nothing is sent and nothing is lost. `GET /internal/mail/outbound` says |
| 140 | whether the switch is on, so the approval card can say so itself. |
| 141 | |
| 142 | ## Contracts |
| 143 | |
| 144 | `mail.mask@1`, `mail.burner@1`, `mail.agent-inbox@1`, `mail.message-summary@1`, `mail.agent-inbox-message@1`, |
| 145 | `mail.agent-reply-request@1`, `mail.agent-reply-result@1`, `mail.inbox-answering@1` and |
| 146 | `mail.agent_inbox.received@1` are in |
| 147 | [OpenVibe.Contracts](https://github.com/OpenVibers/OpenVibe.Contracts) v0.342.0, which this service pins. The |
| 148 | `/me/mail` listing is a projection above them (state, expiry, counts) — the Contracts resources do not grow extra |
| 149 | fields, and no Contracts release is made from this repository. |
| 150 | |
| 151 | ## Stalwart |
| 152 | |
| 153 | Stalwart Community is pinned in [`deploy/stalwart/VERSION`](deploy/stalwart/VERSION) (1.0.3, sha256 verified by |
| 154 | `scripts/fetch-stalwart.js`). Stalwart v0.16 and later stopped reading TOML, so |
| 155 | [`deploy/stalwart/config.toml`](deploy/stalwart/config.toml) is **our** declarative source of truth: |
| 156 | |
| 157 | ```sh |
| 158 | node deploy/stalwart/render.js # → rendered/config.json (the data store) + rendered/plan.json |
| 159 | node deploy/stalwart/apply.js # reconciles the plan over loopback JMAP |
| 160 | ``` |
| 161 | |
| 162 | The plan creates the SQL directory and its queries, the base/mask/burner domains (all `allowRelaying = false`), the |
| 163 | filesystem blob store (or S3 when `STALWART_BLOB_STORE=s3`), the built-in spam filter, and exactly three listeners: |
| 164 | SMTP on 25, JMAP/management and IMAP on loopback. Every default submission/HTTPS/POP3/Sieve listener is removed. The authentication defaults give a |
| 165 | directory-created account the `User` role — without it Stalwart creates the account and then refuses its own mail. |
| 166 | |
| 167 | ## Tests |
| 168 | |
| 169 | ```sh |
| 170 | npm test # unit: PGlite, an in-process Network and an in-memory mail server |
| 171 | npm run test:pg # the same through the PostgreSQL container |
| 172 | npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL, fs blobs by default (MinIO with |
| 173 | # STALWART_BLOB_STORE=s3) |
| 174 | ``` |
| 175 | |
| 176 | The acceptance run (also `test/stalwart/integration.test.js` in CI) starts the pinned binary in recovery mode so |
| 177 | its default listeners do not collide with the test ports, applies the plan, restarts it on the plan's listeners, and |
| 178 | checks: |
| 179 | |
| 180 | - SMTP to a burner lands, `/code` returns the code, and no body or code reaches the service log; |
| 181 | - a message to burner A with forged To, Cc and Delivered-To naming burner B never appears in B's messages or |
| 182 | `/code`; |
| 183 | - 300 messages to burner A do not hide burner B's one message (each burner is its own account); |
| 184 | - a forged Delivered-To does not move mail between two agent inboxes on one person's mailbox; |
| 185 | - a first-message-only burner refuses the second message; |
| 186 | - an expired burner's mail is deleted, its account is removed and the address bounces; |
| 187 | - mail to a disabled mask bounces; |
| 188 | - another person gets 404 for a mask, burner and agent inbox; |
| 189 | - a ten-minute burner works without an account and is read with its token; |
| 190 | - the message blob is on the local disk with `STALWART_BLOB_STORE=fs` (and the same run passes with `s3` in CI); |
| 191 | - the CORS allow-list, the same-site cookie write rule and the `/me/mail` listing. |
| 192 | |
| 193 | ## Layout |
| 194 | |
| 195 | ``` |
| 196 | server/ the API, domains, Stalwart client, events and the due-based purge job |
| 197 | migrations/ NNNN_*.sql (expand-only), including the directory view Stalwart reads |
| 198 | deploy/stalwart/ config.toml, VERSION, render.js, apply.js |
| 199 | deploy/systemd/ the memory-capped Stalwart slice and both units |
| 200 | deploy/nginx/ openvibe.email.conf — the public door (API, health, ready, /auth) |
| 201 | deploy/init-db.js apply migrations before systemd starts the service |
| 202 | test/ unit tests and the real-Stalwart acceptance test |
| 203 | docs/ INSTALL.md and DNS.md |
| 204 | ``` |
| 205 | |
| 206 | ## Limits |
| 207 | |
| 208 | A person has 20 masks and 5 live burners. A 10-minute (account-less) burner is capped at 10 per IP per hour and at |
| 209 | 2,000 live anonymous burners globally; past the global cap the API answers 503 `mail.burner_capacity`. The counters |
| 210 | live in PostgreSQL, so a restart does not hand out a fresh allowance. |
| 211 | |
| 212 | ## Install |
| 213 | |
| 214 | See [`docs/INSTALL.md`](docs/INSTALL.md) and [`docs/DNS.md`](docs/DNS.md). M1 is **not deployed** from this |
| 215 | repository; Opus installs it on the host. |
| 216 | |
| 217 | ## Licence |
| 218 | |
| 219 | AGPL-3.0-only, matching Stalwart Community. OpenVibe.Mail ships configuration and a Node service, not a modified |
| 220 | Stalwart. |
| 221 | |
| 222 | <!-- versions:start --> |
| 223 | - openvibe-contracts: v0.419.0 |
| 224 | - openvibe-sdk: v0.161.0 |
| 225 | - openvibe-shared: v4.241.0 |
| 226 | <!-- versions:end --> |
| 227 |