openvibers/openvibe.mail README.md

README.md

main 14.9 KB · 227 lines · markdown

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
8Mail receives mail for OpenVibe mailboxes and includes opt-in Inbox AI, which can also pull another provider's
9mailbox over IMAP (TLS only, read only, app password sealed with `MAIL_SECRETS_KEY`). Replies stay drafts until the
10person 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
16forward a copy to another email they verified on their Network account. Sending requires a verified account email,
17a one-day-old account, a verified sender domain, and a live Network trust check. The allowance starts at 20
18recipients per day and rises to 50 or 100 with account age and successful history. Each message has at most 10
19recipients. Hard bounces and complaints tied to provider message IDs pause the sender; a report link is in the
20message and its `X-OpenVibe-Report-Abuse` header for SMTP delivery. No bulk list mode is offered.
21
22Forwarding checks Network ownership when a rule is created and again before each copy. A revoked address disables
23its rule. Only non-junk plain-text messages without attachments are copied; an ambiguous provider response is not
24retried automatically. Network service credentials and the `identity.trust.read` and `identity.emails.read` grants
25must be present. `MAIL_OUTBOUND=on` and an enabled provider remain required for external sending and forwarding.
26See [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
31inboxes), 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`
43limited to layout and colour properties whose values can hold no `url()`, `expression()` or `@import`, links only
44http(s)/mailto opening in a new tab with `rel="noopener noreferrer"`. Remote images, the way senders track opening,
45are replaced by their alt text and counted (`blocked_images`) until the person asks (`?images=1`, https only). The
46page (`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
60Raw message bodies are read from Stalwart over loopback JMAP and are never written to logs or events. With Inbox AI
61enabled, summaries and editable reply drafts are stored per thread in Mail; the draft is the only generated body
62persisted. A burner's account is read with no
63recipient 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
68All routes are under `/api/v1` and take an OpenVibe.Network token (`Authorization: Bearer …`), or the person's
69same-site session cookie on openvibe.email (set by `openvibe-sdk/sso` after sign-in; `/auth/*` is the sign-in
70handshake). 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
72token 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
96Somebody else's mask, burner or inbox answers **404**, never 403: the answer must not say it exists. The messages
97API 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
99memory and never returns the body. `GET /api/ready` asks the database and Stalwart; `/api/health` is the liveness
100answer.
101
102`GET /api/v1/me/mail` is the one call the page makes: it returns `masks`, `burners` and `agent_inboxes`, where each
103item 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
105address), 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
106arrives; the purge job disables the due rows and the directory stops resolving them.
107
108## Events
109
110The 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
122An agent's inbox is an address, not an invitation: answering is a **switch, off by default**, that the person turns
123on for one agent. While it is on, the due poll (`server/jobs/agent-mail.js`) reads the newest messages of the
124person's mailbox as that inbox, and for each letter not seen before publishes `mail.agent_inbox.received` once —
125the row that says the letter is the agent's and the event commit in one transaction, so an overlapping poll or a
126restart never announces it twice. Spam, a blocked sender and the inbox's own address start nothing.
127
128The 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`:
130the 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
134mailbox, a mask, a burner — which is local delivery on our own server, no packet off the host. Sending to anyone
135else is plan M2 and waits on the owner (a separate mail IPv4, its PTR record, Workers Paid for the relay), so it is
136behind a switch that is **off by default**: `MAIL_OUTBOUND=on` is the only way on. With it off, `routeFor` answers
137before any connection is opened, `POST …/reply` answers `409 mail.outbound_closed` with the plain sentence "OpenVibe
138Mail cannot send outside OpenVibe yet: the reply waits here", and the agent's system turns that into the person's
139send approval: the reply stays pending, nothing is sent and nothing is lost. `GET /internal/mail/outbound` says
140whether 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
149fields, and no Contracts release is made from this repository.
150
151## Stalwart
152
153Stalwart 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
158node deploy/stalwart/render.js # → rendered/config.json (the data store) + rendered/plan.json
159node deploy/stalwart/apply.js # reconciles the plan over loopback JMAP
160```
161
162The plan creates the SQL directory and its queries, the base/mask/burner domains (all `allowRelaying = false`), the
163filesystem blob store (or S3 when `STALWART_BLOB_STORE=s3`), the built-in spam filter, and exactly three listeners:
164SMTP on 25, JMAP/management and IMAP on loopback. Every default submission/HTTPS/POP3/Sieve listener is removed. The authentication defaults give a
165directory-created account the `User` role — without it Stalwart creates the account and then refuses its own mail.
166
167## Tests
168
169```sh
170npm test # unit: PGlite, an in-process Network and an in-memory mail server
171npm run test:pg # the same through the PostgreSQL container
172npm run test:stalwart # the acceptance run: real Stalwart + PostgreSQL, fs blobs by default (MinIO with
173 # STALWART_BLOB_STORE=s3)
174```
175
176The acceptance run (also `test/stalwart/integration.test.js` in CI) starts the pinned binary in recovery mode so
177its default listeners do not collide with the test ports, applies the plan, restarts it on the plan's listeners, and
178checks:
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```
196server/ the API, domains, Stalwart client, events and the due-based purge job
197migrations/ NNNN_*.sql (expand-only), including the directory view Stalwart reads
198deploy/stalwart/ config.toml, VERSION, render.js, apply.js
199deploy/systemd/ the memory-capped Stalwart slice and both units
200deploy/nginx/ openvibe.email.conf — the public door (API, health, ready, /auth)
201deploy/init-db.js apply migrations before systemd starts the service
202test/ unit tests and the real-Stalwart acceptance test
203docs/ INSTALL.md and DNS.md
204```
205
206## Limits
207
208A person has 20 masks and 5 live burners. A 10-minute (account-less) burner is capped at 10 per IP per hour and at
2092,000 live anonymous burners globally; past the global cap the API answers 503 `mail.burner_capacity`. The counters
210live in PostgreSQL, so a restart does not hand out a fresh allowance.
211
212## Install
213
214See [`docs/INSTALL.md`](docs/INSTALL.md) and [`docs/DNS.md`](docs/DNS.md). M1 is **not deployed** from this
215repository; Opus installs it on the host.
216
217## Licence
218
219AGPL-3.0-only, matching Stalwart Community. OpenVibe.Mail ships configuration and a Node service, not a modified
220Stalwart.
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