openvibers/openvibe.mail README.md

README.md

main 14.9 KB · 227 lines · markdown

OpenVibe.Mail

openvibe.email — masks that protect you, burners that self-destruct, and an inbox for every agent.

Live site: https://openvibe.email · API docs: https://openvibe.services · For AI: https://openvibe.email/llms.txt
Run it: npm ci && npm start (Node 22 or newer) · Tests: npm test

Mail receives mail for OpenVibe mailboxes and includes opt-in Inbox AI, which can also pull another provider's
mailbox over IMAP (TLS only, read only, app password sealed with MAIL_SECRETS_KEY). Replies stay drafts until the
person edits and clicks Send; external delivery remains closed unless the configured outbound relay is enabled. See
[Inbox AI](docs/INBOX-AI.md) for the consent, rules, journal, undo and receipts APIs.

Person mail

/inbox also lets a person create a named address or alias, compose plain-text mail, attach a custom domain, and
forward a copy to another email they verified on their Network account. Sending requires a verified account email,
a one-day-old account, a verified sender domain, and a live Network trust check. The allowance starts at 20
recipients per day and rises to 50 or 100 with account age and successful history. Each message has at most 10
recipients. Hard bounces and complaints tied to provider message IDs pause the sender; a report link is in the
message and its X-OpenVibe-Report-Abuse header for SMTP delivery. No bulk list mode is offered.

Forwarding checks Network ownership when a rule is created and again before each copy. A revoked address disables
its rule. Only non-junk plain-text messages without attachments are copied; an ambiguous provider response is not
retried automatically. Network service credentials and the identity.trust.read and identity.emails.read grants
must be present. MAIL_OUTBOUND=on and an enabled provider remain required for external sending and forwarding.
See [custom-domain DNS](docs/DNS.md) for the inbound path and verification states.

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 (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 |
|---|---|
| Masks | [email protected] — a per-site address that delivers into your OpenVibe mailbox and nowhere else. Turn it off and it bounces. |
| 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. |
| Agent inboxes | [email protected] — an address an agent can read, on the person's own domain. |
| 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. |
| 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. |

Raw message bodies are read from Stalwart over loopback JMAP and are never written to logs or events. With Inbox AI
enabled, summaries and editable reply drafts are stored per thread in Mail; the draft is the only generated body
persisted. A burner's account is read with no
recipient filtering at all; a mask/agent inbox in the shared person mailbox is matched only against the first
Delivered-To header, the one Stalwart stamps — never a sender-written To, Cc or Delivered-To.

API

All routes are under /api/v1 and take an OpenVibe.Network token (Authorization: Bearer …), or the person's
same-site session cookie on openvibe.email (set by openvibe-sdk/sso after sign-in; /auth/* is the sign-in
handshake). A cross-origin browser may send a Bearer token from any first-party origin in the Contracts manifests
(CORS); a cookie-authenticated write must come from openvibe.email itself (Sec-Fetch-Site/Origin). An app
token must name the person it acts for (on_behalf_of) and carry mail.addresses.write or
mail.messages.read. A typed token (a typ or purpose claim) is refused.

| Method | Route | Who |
|---|---|---|
| GET | /me/mail | a person; masks, burners and agent inboxes with state, expiry and counts (see below) |
| POST | /masks | a person; { purpose, expires_days?, label? } |
| GET | /masks | a person |
| GET | /masks/:id | the owner; anyone else gets 404 |
| POST | /masks/:id/disable | the owner; turns the mask off |
| DELETE | /masks/:id | the same, for a DELETE-shaped client |
| POST | /burners | a person, or nobody ({ expires: "10m" \| "1h" \| "1d" \| "7d", first_message_only }) |
| GET | /burners | a person |
| GET | /burners/:id/messages | the owner, or the holder of the returned x-mail-burner-token |
| GET | /burners/:id/code | the same; the newest verification code, extracted from text or HTML |
| POST | /agent-inboxes | a person; { agent_id } |
| GET | /agent-inboxes | a person |
| GET | /agent-inboxes/:id/messages | the owner |
| GET | /agent-inboxes/:id/answering | the owner; the switch, off by default (mail.inbox-answering@1) |
| PUT | /agent-inboxes/:id/answering | the owner; { enabled } turns an agent's answering on or off |
| GET | /me/mail/blocks | the person's blocked senders |
| POST | /me/mail/blocks | the person; { address } |
| DELETE | /me/mail/blocks/:address | the person's own block |

Somebody else's mask, burner or inbox answers 404, never 403: the answer must not say it exists. The messages
API returns an array of { id, from, subject, received_at } and nothing else: no preview, no body, no HTML. The
/code endpoint returns { code, message_id, received_at, from }; it extracts the code from text or HTML in
memory and never returns the body. GET /api/ready asks the database and Stalwart; /api/health is the liveness
answer.

GET /api/v1/me/mail is the one call the page makes: it returns masks, burners and agent_inboxes, where each
item carries the Contracts resource (mask, burner, agent_inbox) exactly, beside the listing fields address,
purpose, state, a mask's expires_at, and counts.messages (the messages the service has already seen for that
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
arrives; the purge job disables the due rows and the directory stops resolving them.

Events

The outbox (event_outbox) publishes, through OpenVibe.Events when configured:

  • mail.mask.created — the mask exists;
  • mail.received — metadata only: the address, the sender, the message id and the time. No subject, no body,
    no code.
  • mail.agent_inbox.received — an agent's address whose answering the person turned ON. The inbox and the agent,
    the owner, the message id, the sender, the subject, and a plain-text excerpt of at most 4000 characters.
    Never the HTML, never an attachment, and nothing at all for a message the mail server marked as spam or a sender
    on the person's block list.

An agent answers the mail sent to its own address (HO6)

An agent's inbox is an address, not an invitation: answering is a switch, off by default, that the person turns
on for one agent. While it is on, the due poll (server/jobs/agent-mail.js) reads the newest messages of the
person's mailbox as that inbox, and for each letter not seen before publishes mail.agent_inbox.received once —
the row that says the letter is the agent's and the event commit in one transaction, so an overlapping poll or a
restart never announces it twice. Spam, a blocked sender and the inbox's own address start nothing.

The agent's system then acts on the event. It reads the whole letter in plain text through mail.agent.read
(loopback, first-party service token, the person named in X-OV-Subject), and answers through mail.agent.reply:
the reply is sent from the agent's address, to the sender of the letter and to nobody else, plain text, with
In-Reply-To/References read from the letter itself. Every send is recorded in mail_sent_mail with its outcome.

Outbound is not ours yet. A reply goes only to one of the owner's own openvibe.email addresses — their
mailbox, a mask, a burner — which is local delivery on our own server, no packet off the host. Sending to anyone
else is plan M2 and waits on the owner (a separate mail IPv4, its PTR record, Workers Paid for the relay), so it is
behind a switch that is off by default: MAIL_OUTBOUND=on is the only way on. With it off, routeFor answers
before any connection is opened, POST …/reply answers 409 mail.outbound_closed with the plain sentence "OpenVibe
Mail cannot send outside OpenVibe yet: the reply waits here", and the agent's system turns that into the person's
send approval: the reply stays pending, nothing is sent and nothing is lost. GET /internal/mail/outbound says
whether the switch is on, so the approval card can say so itself.

Contracts

mail.mask@1, mail.burner@1, mail.agent-inbox@1, mail.message-summary@1, mail.agent-inbox-message@1,
mail.agent-reply-request@1, mail.agent-reply-result@1, mail.inbox-answering@1 and
mail.agent_inbox.received@1 are in
OpenVibe.Contracts v0.342.0, which this service pins. The
/me/mail listing is a projection above them (state, expiry, counts) — the Contracts resources do not grow extra
fields, and no Contracts release is made from this repository.

Stalwart

Stalwart Community is pinned in [deploy/stalwart/VERSION](deploy/stalwart/VERSION) (1.0.3, sha256 verified by
scripts/fetch-stalwart.js). Stalwart v0.16 and later stopped reading TOML, so
[deploy/stalwart/config.toml](deploy/stalwart/config.toml) is our declarative source of truth:

node deploy/stalwart/render.js   # → rendered/config.json (the data store) + rendered/plan.json
node deploy/stalwart/apply.js    # reconciles the plan over loopback JMAP

The plan creates the SQL directory and its queries, the base/mask/burner domains (all allowRelaying = false), the
filesystem blob store (or S3 when STALWART_BLOB_STORE=s3), the built-in spam filter, and exactly three listeners:
SMTP on 25, JMAP/management and IMAP on loopback. Every default submission/HTTPS/POP3/Sieve listener is removed. The authentication defaults give a
directory-created account the User role — without it Stalwart creates the account and then refuses its own mail.

Tests

npm test               # unit: PGlite, an in-process Network and an in-memory mail server
npm run test:pg        # the same through the PostgreSQL container
npm run test:stalwart  # the acceptance run: real Stalwart + PostgreSQL, fs blobs by default (MinIO with
                       # STALWART_BLOB_STORE=s3)

The acceptance run (also test/stalwart/integration.test.js in CI) starts the pinned binary in recovery mode so
its default listeners do not collide with the test ports, applies the plan, restarts it on the plan's listeners, and
checks:

  • SMTP to a burner lands, /code returns the code, and no body or code reaches the service log;
  • a message to burner A with forged To, Cc and Delivered-To naming burner B never appears in B's messages or
    /code;
  • 300 messages to burner A do not hide burner B's one message (each burner is its own account);
  • a forged Delivered-To does not move mail between two agent inboxes on one person's mailbox;
  • a first-message-only burner refuses the second message;
  • an expired burner's mail is deleted, its account is removed and the address bounces;
  • mail to a disabled mask bounces;
  • another person gets 404 for a mask, burner and agent inbox;
  • a ten-minute burner works without an account and is read with its token;
  • the message blob is on the local disk with STALWART_BLOB_STORE=fs (and the same run passes with s3 in CI);
  • the CORS allow-list, the same-site cookie write rule and the /me/mail listing.

Layout

server/            the API, domains, Stalwart client, events and the due-based purge job
migrations/        NNNN_*.sql (expand-only), including the directory view Stalwart reads
deploy/stalwart/   config.toml, VERSION, render.js, apply.js
deploy/systemd/    the memory-capped Stalwart slice and both units
deploy/nginx/      openvibe.email.conf — the public door (API, health, ready, /auth)
deploy/init-db.js  apply migrations before systemd starts the service
test/              unit tests and the real-Stalwart acceptance test
docs/              INSTALL.md and DNS.md

Limits

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
2,000 live anonymous burners globally; past the global cap the API answers 503 mail.burner_capacity. The counters
live in PostgreSQL, so a restart does not hand out a fresh allowance.

Install

See [docs/INSTALL.md](docs/INSTALL.md) and [docs/DNS.md](docs/DNS.md). M1 is not deployed from this
repository; Opus installs it on the host.

Licence

AGPL-3.0-only, matching Stalwart Community. OpenVibe.Mail ships configuration and a Node service, not a modified
Stalwart.

<!-- versions:start -->

  • openvibe-contracts: v0.419.0
  • openvibe-sdk: v0.161.0
  • openvibe-shared: v4.237.0

<!-- versions:end -->