openvibers/openvibe.mail Commits
Add person mail sending, verified forwarding and custom domains (#29)
Changes
@@@ -86,6 +86,9 @@ MAIL_EVENTS_CLIENT_SECRET=# reply to an outside address opens no connection at all and the agent's system turns it into the person's approval.MAIL_SMTP_HOST=127.0.0.1MAIL_SMTP_PORT=25++# MX target for a person's custom domain. Set to the hostname that hands inbound SMTP to this Stalwart instance.++# A custom domain cannot pass MX verification while this is empty.++#MAIL_CUSTOM_DOMAIN_MX=mail.openvibe.email#MAIL_OUTBOUND=off#MAIL_RELAY_HOST=#MAIL_RELAY_PORT=587@@@ -7,6 +7,21 @@ mailbox over IMAP (TLS only, read only, app password sealed with `MAIL_SECRETS_Kperson 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@@@ -1,5 +1,20 @@# DNS for M1 (receive only)++## Person-owned custom domains++++Add the domain in `/inbox`. Mail gives the owner a unique `_openvibe-mail.<domain>` TXT value and the required++MX, provider DKIM, and SPF records. Set `MAIL_CUSTOM_DOMAIN_MX` to the hostname that delivers SMTP to this++Stalwart instance (for the current direct inbound route, `mail.openvibe.email`). The owner adds these records at++their DNS provider and clicks **Check DNS**. Mail moves through `pending_ownership`, `pending_mx`, `pending_dkim`,++then `active`; it registers the domain in Stalwart only after TXT and MX match. The SQL directory then maps each++claimed custom address to the person's existing mailbox. Sending as the custom domain requires at least one++enabled outbound provider with matching DKIM DNS and a verified domain status. Rechecking removes sending status++if DNS is withdrawn.++++Cloudflare DNS can host these records. A domain using Cloudflare Email Routing needs its own routing rule to deliver++to the Stalwart inbound hostname; configuring DNS alone does not create that rule. Keep `MAIL_CUSTOM_DOMAIN_MX`++matched to the actual receiving route. Mail never creates a Cloudflare account or zone for the owner.++M1 receives for `openvibe.email`, `mask.openvibe.email` and each burner subdomain in `BURNER_DOMAINS`. It does notsend: until M2 there is no DKIM key and no sending path, so SPF and DMARC are strict and closed.nnew file mode 100644@@@ -0,0 +1,81 @@++-- phase: expand++-- Person-owned addresses, custom domains, outbound allowance and verified forwarding.++CREATE TABLE IF NOT EXISTS mail_custom_domains (++ domain text NOT NULL,++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ ownership_token text NOT NULL,++ ownership_verified_at timestamptz,++ mx_verified_at timestamptz,++ dkim_verified_at timestamptz,++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (domain, owner_subject)++);++CREATE INDEX IF NOT EXISTS mail_custom_domains_owner ON mail_custom_domains(owner_subject);++CREATE UNIQUE INDEX IF NOT EXISTS mail_custom_domains_verified_domain ON mail_custom_domains(domain) WHERE ownership_verified_at IS NOT NULL;++++CREATE TABLE IF NOT EXISTS mail_person_addresses (++ address text PRIMARY KEY,++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ kind text NOT NULL CHECK (kind IN ('name','alias','custom')),++ enabled boolean NOT NULL DEFAULT true,++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS mail_person_addresses_owner ON mail_person_addresses(owner_subject,created_at);++++CREATE TABLE IF NOT EXISTS mail_person_daily (++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ day date NOT NULL,++ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),++ accepted integer NOT NULL DEFAULT 0 CHECK (accepted >= 0),++ PRIMARY KEY(owner_subject,day)++);++CREATE TABLE IF NOT EXISTS mail_person_sends (++ id text PRIMARY KEY,++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ sender text NOT NULL,++ recipient text NOT NULL,++ provider_id text,++ provider_message_id text,++ status text NOT NULL CHECK(status IN ('sending','accepted','failed','uncertain','bounced','complaint')),++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS mail_person_sends_owner ON mail_person_sends(owner_subject,created_at DESC);++CREATE INDEX IF NOT EXISTS mail_person_sends_provider_message ON mail_person_sends(provider_id,provider_message_id) WHERE provider_message_id IS NOT NULL;++CREATE TABLE IF NOT EXISTS mail_person_reports (++ send_id text PRIMARY KEY REFERENCES mail_person_sends(id) ON DELETE CASCADE,++ created_at timestamptz NOT NULL DEFAULT now()++);++++CREATE TABLE IF NOT EXISTS mail_forward_rules (++ id text PRIMARY KEY,++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ source_address text,++ target_address text NOT NULL,++ enabled boolean NOT NULL DEFAULT true,++ created_at timestamptz NOT NULL DEFAULT now(),++ UNIQUE(owner_subject,source_address,target_address)++);++CREATE INDEX IF NOT EXISTS mail_forward_rules_owner ON mail_forward_rules(owner_subject);++CREATE TABLE IF NOT EXISTS mail_forward_daily (++ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,++ day date NOT NULL,++ attempts integer NOT NULL DEFAULT 0 CHECK(attempts >= 0),++ PRIMARY KEY(owner_subject,day)++);++CREATE TABLE IF NOT EXISTS mail_forward_deliveries (++ rule_id text NOT NULL REFERENCES mail_forward_rules(id) ON DELETE CASCADE,++ message_id text NOT NULL,++ state text NOT NULL CHECK(state IN ('sending','sent','failed')),++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY(rule_id,message_id)++);++++CREATE OR REPLACE VIEW mail_directory_accounts AS++ SELECT mb.address,mb.address AS name,mb.owner_subject AS owner,''::text AS secret,'mailbox'::text AS description FROM mail_mailboxes mb++ UNION ALL SELECT m.address,mb.address,m.owner_subject,''::text,'mask'::text FROM mail_masks m JOIN mail_mailboxes mb USING(owner_subject) WHERE m.enabled++ UNION ALL SELECT b.address,b.address,b.owner_subject,''::text,'burner'::text FROM mail_burners b WHERE b.expires_at > now() AND (NOT b.first_message_only OR b.first_message_at IS NULL)++ UNION ALL SELECT a.address,mb.address,a.owner_subject,''::text,'agent inbox'::text FROM mail_agent_inboxes a JOIN mail_mailboxes mb USING(owner_subject)++ UNION ALL SELECT p.address,mb.address,p.owner_subject,''::text,'person address'::text FROM mail_person_addresses p JOIN mail_mailboxes mb USING(owner_subject)++ WHERE p.enabled AND (p.kind <> 'custom' OR EXISTS (++ SELECT 1 FROM mail_custom_domains d WHERE d.domain=split_part(p.address,'@',2)++ AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL AND d.mx_verified_at IS NOT NULL));@@@ -14,8 +14,8 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","imapflow": "2.11.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0","pg": "^8.23.0","sanitize-html": "^2.18.1"@@@ -1259,9 +1259,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.342.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",-- "integrity": "sha512-gSHwLxPVYSkPN2bZScIlyG6KhxjnKdAIjRomBhm/XngrgfboZR58rfvUG9iLV2ekJPRcX5UxvABL+B/9vtLFkg==",++ "version": "0.349.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",++ "integrity": "sha512-SatU6LdZ1giDM6aszhoqYcZGmk+94V3Tg9FlacaWgDZZNIrxDbYmiz32CKPTsuLC9W+fgQxQcRw4Si8+OOsHMQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1275,9 +1275,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.97.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",-- "integrity": "sha512-zCRmu7NoOi7kI0v04N8uypCgsuEcf9kCzkw88r0+dj9PG3AA/z+Pp4BZaX6Z1JUE3sdrtc17D5LcD05IbWsw8w==",++ "version": "0.102.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",++ "integrity": "sha512-ItPOSRFvXIdFkVRF4/eDO0RUG0GJkJmfzebG5vO6WofYEVvWUdPok0VwKK5fgoOhbnZLoWWKoRCiiNG52/neaA==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -2,7 +2,7 @@"name": "openvibe-mail","version": "0.1.0","private": true,-- "description": "OpenVibe.Mail \u2014 openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",++ "description": "OpenVibe.Mail — openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -28,8 +28,8 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","imapflow": "2.11.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0","pg": "^8.23.0","sanitize-html": "^2.18.1"@@@ -49,6 +49,19 @@ main[data-mail-inbox] [hidden] { display: none !important; }.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-settings { min-width: 0; border-top: 1px solid var(--ib-line); }++.ib-settings > summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; color: var(--ib-muted); font-size: 12px; font-weight: 700; }++.ib-settings > summary::after { content: '⌄'; margin-left: auto; font-size: 18px; }++.ib-settings[open] > summary::after { transform: rotate(180deg); }++.ib-settings[open] > summary { color: var(--ib-text); }++.ib-person { display: grid; gap: 10px; padding-top: 14px; border-top: 1px solid var(--ib-line); }++.ib-person .ib-h { margin: 8px 0 0; }++.ib-person .ib-quiet { margin: 0; font-size: 12px; }++.ib-person-domain, .ib-forward { display: grid; gap: 6px; min-width: 0; padding: 8px; border: 1px solid var(--ib-line); border-radius: 10px; overflow-wrap: anywhere; font-size: 12px; }++.ib-dns { max-width: 100%; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ib-muted); font-size: 11px; }++.ib-compose { display: grid; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--ib-line); }++.ib-compose .ib-quiet { margin: 0; white-space: pre-line; font-size: 12px; }++.ib-compose textarea { resize: vertical; }.ib-ai-settings { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--ib-line); }.ib-ai-label { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 650; }.ib-ai-label input { accent-color: var(--ib-accent, #6657d9); }@@@ -20,8 +20,9 @@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, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [];-- var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner' };++ var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [], customDomains = [], forwards = [];++ var composeDraft = { from: '', to: '', subject: '', body: '' };++ var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner', name: 'Your address', alias: 'Alias', custom: 'Custom domain' };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) {@@@ -52,6 +53,7 @@// ── the addresses ──function paintSide() {++ var settingsOpen = !!(side.querySelector('.ib-settings') && side.querySelector('.ib-settings').open);side.textContent = '';side.appendChild(el('h2', 'ib-h', 'Addresses'));var ul = el('ul', 'ib-addrs');@@@ -71,6 +73,57 @@ul.appendChild(li);});side.appendChild(ul);++ var settings = el('details', 'ib-settings'); settings.open = settingsOpen;++ settings.appendChild(el('summary', '', 'Mail settings · ' + customDomains.length + ' domains · ' + forwards.length + ' forwards'));++ var personal = el('section', 'ib-person');++ personal.appendChild(el('h2', 'ib-h', 'Your address'));++ var addressForm = el('form', 'ib-rule-form');++ var addressName = el('input', 'ib-input'); addressName.placeholder = 'name'; addressName.required = true; addressName.setAttribute('aria-label', 'New address name');++ var addressDomain = el('select', 'ib-input'); addressDomain.setAttribute('aria-label', 'Address domain');++ [{ domain: 'openvibe.email' }].concat(customDomains.filter(function (d) { return d.mx_verified; })).forEach(function (d) { var option = el('option', '', '@' + d.domain); option.value = d.domain; addressDomain.appendChild(option); });++ var addressGo = el('button', 'ib-btn is-quiet', 'Create address'); addressGo.type = 'submit';++ var addressNote = el('p', 'ib-quiet');++ addressForm.appendChild(addressName); addressForm.appendChild(addressDomain); addressForm.appendChild(addressGo); addressForm.appendChild(addressNote);++ addressForm.addEventListener('submit', function (event) {++ event.preventDefault(); addressGo.disabled = true;++ api('/api/v1/me/mail/person-addresses', { method: 'POST', body: { name: addressName.value, domain: addressDomain.value, kind: addressDomain.value !== 'openvibe.email' ? 'custom' : addresses.some(function (a) { return a.kind === 'name'; }) ? 'alias' : 'name' } })++ .then(function () { return api('/api/v1/me/mail/addresses'); }).then(function (result) { addresses = result.addresses || []; paintSide(); })++ .catch(function (err) { addressGo.disabled = false; addressNote.textContent = err.message; });++ });++ personal.appendChild(addressForm);++ personal.appendChild(el('h2', 'ib-h', 'Custom domains'));++ customDomains.forEach(function (domain) {++ var card = el('div', 'ib-person-domain'); card.appendChild(el('strong', '', domain.domain + ' · ' + domain.status.replace(/_/g, ' ')));++ var records = el('pre', 'ib-dns', JSON.stringify(domain.dns, null, 2)); card.appendChild(records);++ var verify = el('button', 'ib-btn is-quiet', 'Check DNS'); verify.type = 'button';++ verify.addEventListener('click', function () { verify.disabled = true; api('/api/v1/me/mail/custom-domains/' + encodeURIComponent(domain.domain) + '/verify', { method: 'POST' }).then(loadPersonControls).then(paintSide).catch(function (err) { verify.disabled = false; card.appendChild(el('p', 'ib-problem', err.message)); }); });++ card.appendChild(verify); personal.appendChild(card);++ });++ var domainForm = el('form', 'ib-rule-form');++ var domainName = el('input', 'ib-input'); domainName.placeholder = 'example.com'; domainName.setAttribute('aria-label', 'Custom domain'); domainName.required = true;++ var domainGo = el('button', 'ib-btn is-quiet', 'Add domain'); domainGo.type = 'submit';++ var domainNote = el('p', 'ib-quiet');++ domainForm.appendChild(domainName); domainForm.appendChild(domainGo); domainForm.appendChild(domainNote);++ domainForm.addEventListener('submit', function (event) { event.preventDefault(); domainGo.disabled = true; api('/api/v1/me/mail/custom-domains', { method: 'POST', body: { domain: domainName.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { domainGo.disabled = false; domainNote.textContent = err.message; }); });++ personal.appendChild(domainForm);++ personal.appendChild(el('h2', 'ib-h', 'Forward to your verified email'));++ personal.appendChild(el('p', 'ib-quiet', 'Add and verify your other address on your Network account first. A copy stays in this inbox.'));++ forwards.forEach(function (rule) {++ var line = el('div', 'ib-forward'); line.appendChild(el('span', '', (rule.source_address || 'All mail') + ' → ' + rule.target_address));++ var remove = el('button', 'ib-btn is-quiet', 'Remove'); remove.type = 'button';++ remove.addEventListener('click', function () { remove.disabled = true; api('/api/v1/me/mail/forwards/' + encodeURIComponent(rule.id), { method: 'DELETE' }).then(loadPersonControls).then(paintSide).catch(function () { remove.disabled = false; }); });++ line.appendChild(remove); personal.appendChild(line);++ });++ var forwardForm = el('form', 'ib-rule-form');++ var forwardSource = el('select', 'ib-input'); forwardSource.setAttribute('aria-label', 'Forward mail sent to');++ [{ address: 'all' }].concat(addresses).forEach(function (a) { var option = el('option', '', a.address === 'all' ? 'All mail' : a.address); option.value = a.address; forwardSource.appendChild(option); });++ var forwardTarget = el('input', 'ib-input'); forwardTarget.type = 'email'; forwardTarget.placeholder = '[email protected]'; forwardTarget.required = true; forwardTarget.setAttribute('aria-label', 'Verified forwarding email');++ var forwardGo = el('button', 'ib-btn is-quiet', 'Add forwarding'); forwardGo.type = 'submit';++ var forwardNote = el('p', 'ib-quiet');++ forwardForm.appendChild(forwardSource); forwardForm.appendChild(forwardTarget); forwardForm.appendChild(forwardGo); forwardForm.appendChild(forwardNote);++ forwardForm.addEventListener('submit', function (event) { event.preventDefault(); forwardGo.disabled = true; api('/api/v1/me/mail/forwards', { method: 'POST', body: { source: forwardSource.value, target: forwardTarget.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { forwardGo.disabled = false; forwardNote.textContent = err.message; }); });++ personal.appendChild(forwardForm);++ settings.appendChild(personal);var aiBox = el('section', 'ib-ai-settings');aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI onaiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));@@@ -88,7 +141,7 @@aiBox.appendChild(aiLabel);var aiCopy = el('p', 'ib-quiet', aiEnabled ? 'On · uses your OpenVibe.AI allowance. Drafts wait for your click.' : 'Off · your mail is not sent for AI triage.');aiBox.appendChild(aiCopy);-- side.appendChild(aiBox);++ settings.appendChild(aiBox);// Connected mailboxes: Inbox AI reading another provider over IMAP. Read only, app password, and Disconnect is// the way back out; the summaries it pulled go only when the person asks.var imapBox = el('section', 'ib-ai-rules ib-imap');@@@ -133,7 +186,7 @@} else if (!imapMailboxes.length) {imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));}-- side.appendChild(imapBox);++ settings.appendChild(imapBox);var rulesBox = el('section', 'ib-ai-rules');rulesBox.appendChild(el('h2', 'ib-h', 'Rules'));function ruleForm(rule) {@@@ -165,7 +218,7 @@}aiRules.forEach(function (rule) { rulesBox.appendChild(ruleForm(rule)); });rulesBox.appendChild(ruleForm({ category: 'other', enabled: true }));-- side.appendChild(rulesBox);++ settings.appendChild(rulesBox);var journalBox = el('section', 'ib-ai-journal');journalBox.appendChild(el('h2', 'ib-h', 'Rule journal'));aiJournal.forEach(function (entry) {@@@ -178,7 +231,8 @@} else line.appendChild(el('span', 'ib-quiet', 'Undone'));journalBox.appendChild(line);});-- side.appendChild(journalBox);++ settings.appendChild(journalBox);++ side.appendChild(settings);// New mask: one line, for one site.var form = el('form', 'ib-newmask');var input = el('input', 'ib-input');@@@ -230,6 +284,24 @@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);++ var compose = el('form', 'ib-compose');++ compose.appendChild(el('h3', 'ib-h', 'Write a message'));++ var from = el('select', 'ib-input'); from.setAttribute('aria-label', 'Send from');++ addresses.filter(function (a) { return ['mailbox','name','alias','custom'].indexOf(a.kind) >= 0; })++ .sort(function (a, b) { return ['name','alias','custom','mailbox'].indexOf(a.kind) - ['name','alias','custom','mailbox'].indexOf(b.kind); })++ .forEach(function (a) { var option = el('option', '', a.address); option.value = a.address; from.appendChild(option); });++ from.value = composeDraft.from || (from.options[0] && from.options[0].value) || '';++ var to = el('input', 'ib-input'); to.type = 'text'; to.placeholder = 'Recipient email, or up to 10 separated by commas'; to.required = true; to.value = composeDraft.to; to.setAttribute('aria-label', 'Recipients');++ var subject = el('input', 'ib-input'); subject.placeholder = 'Subject'; subject.required = true; subject.value = composeDraft.subject; subject.setAttribute('aria-label', 'Subject');++ var body = el('textarea', 'ib-input'); body.placeholder = 'Your message'; body.required = true; body.rows = 5; body.value = composeDraft.body; body.setAttribute('aria-label', 'Message');++ var send = el('button', 'ib-btn', 'Send'); send.type = 'submit';++ var sendNote = el('p', 'ib-quiet');++ [from,to,subject,body].forEach(function (input) { input.addEventListener('input', function () { composeDraft = { from: from.value, to: to.value, subject: subject.value, body: body.value }; }); compose.appendChild(input); });++ compose.appendChild(send); compose.appendChild(sendNote);++ compose.addEventListener('submit', function (event) { event.preventDefault(); send.disabled = true; api('/api/v1/me/mail/send', { method: 'POST', body: { from: from.value, to: to.value.split(',').map(function (s) { return s.trim(); }), subject: subject.value, body: body.value } })++ .then(function (result) { sendNote.textContent = (result.results || []).map(function (r) { return r.to + ': ' + r.status + (r.reason ? ' · ' + r.reason : ''); }).join('\n'); send.disabled = false; if ((result.results || []).every(function (r) { return r.status === 'accepted'; })) { composeDraft = { from: from.value, to: '', subject: '', body: '' }; to.value = ''; subject.value = ''; body.value = ''; } })++ .catch(function (err) { send.disabled = false; sendNote.textContent = err.message; }); });++ listEl.appendChild(compose);if (current === 'all' && triageThreads.length) {var groups = [['needs_reply', 'Needs a reply'], ['to_read', 'To read'], ['receipts', 'Receipts'],@@@ -426,6 +498,7 @@// The addresses come from the API when the shell does not carry them.function begin() {paintSide();++ loadPersonControls().then(function () { paintSide(); paintList(); }).catch(function () {});loadAiControls().then(function () {paintSide();// A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.@@@ -440,6 +513,10 @@return Promise.all([api('/api/v1/me/mail/ai-settings'), api('/api/v1/me/mail/ai-rules'), api('/api/v1/me/mail/ai-journal'), api('/api/v1/me/mail/imap-mailboxes')]).then(function (results) { aiEnabled = !!results[0].enabled; aiRules = results[1].rules || []; aiJournal = results[2].entries || []; imapMailboxes = results[3].mailboxes || []; });}++ function loadPersonControls() {++ return Promise.all([api('/api/v1/me/mail/custom-domains'), api('/api/v1/me/mail/forwards')])++ .then(function (results) { customDomains = results[0].domains || []; forwards = results[1].forwards || []; });++ }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);@@@ -123,6 +123,7 @@ function load(overrides = null) {mask: maskDomain,burners: burnerDomains,},++ inbound: { mx: list('MAIL_CUSTOM_DOMAIN_MX') },stalwart: {url: (env.STALWART_URL || 'http://127.0.0.1:8080').replace(/\/+$/, ''),nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++++const crypto = require('node:crypto');++const dns = require('node:dns/promises');++const { sql } = require('openvibe-sdk/db');++const { validDomain } = require('./provider-domains');++const providers = require('./outbound-providers');++const adapters = require('./provider-adapters');++const { ensureOwnerMailbox } = require('./mailboxes');++++const txtValues = async (host, lookupTxt) => (await lookupTxt(host).catch(() => [])).map((parts) => Array.isArray(parts) ? parts.join('') : String(parts));++const statusOf = (row) => !row.ownership_verified_at ? 'pending_ownership' : !row.mx_verified_at ? 'pending_mx' : !row.dkim_verified_at ? 'pending_dkim' : 'active';++++async function add({ db, config, owner, domain: raw }) {++ const domain = validDomain(raw);++ if (domain === config.addresses.base || domain.endsWith(`.${config.addresses.base}`) || config.addresses.burners.includes(domain)) throw new Error('This domain belongs to OpenVibe.');++ const token = crypto.randomBytes(24).toString('hex');++ await db.tx((t) => ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base }));++ const verified = await db.maybe(sql`SELECT owner_subject FROM mail_custom_domains WHERE domain=${domain} AND ownership_verified_at IS NOT NULL`);++ if (verified && verified.owner_subject !== owner) throw new Error('This domain is already in use on OpenVibe.');++ await db.exec(sql`INSERT INTO mail_custom_domains(domain,owner_subject,ownership_token) VALUES(${domain},${owner},${token})++ ON CONFLICT (domain,owner_subject) DO NOTHING`);++ return get({ db, config, owner, domain });++}++++async function get({ db, config, owner, domain }) {++ const row = await db.maybe(sql`SELECT domain,ownership_token,ownership_verified_at,mx_verified_at,dkim_verified_at FROM mail_custom_domains WHERE domain=${domain} AND owner_subject=${owner}`);++ if (!row) return null;++ const configured = await db.many(sql`SELECT d.provider_id,d.dkim_records,d.spf_include,p.name FROM mail_outbound_domains d JOIN mail_outbound_providers p ON p.id=d.provider_id AND p.enabled=true WHERE d.domain=${domain}`);++ return { domain, status: statusOf(row), ownership_verified: !!row.ownership_verified_at, mx_verified: !!row.mx_verified_at,++ dkim_verified: !!row.dkim_verified_at, dns: { ownership: { type: 'TXT', name: `_openvibe-mail.${domain}`, value: row.ownership_token },++ mx: (config.inbound?.mx || []).map((host, i) => ({ type: 'MX', name: domain, priority: (i+1)*10, value: host })),++ dkim: configured.flatMap((item) => item.dkim_records || []),++ spf: `v=spf1 ${[...new Set(configured.map((item) => item.spf_include).filter(Boolean))].map((host) => `include:${host}`).join(' ')} -all`.replace(/\s+/g,' ').trim() },++ providers: configured.map((item) => ({ id: item.provider_id, name: item.name })) };++}++++async function verify({ db, config, stalwart, owner, domain: raw, lookupTxt = dns.resolveTxt, lookupMx = dns.resolveMx, fetchImpl = globalThis.fetch }) {++ const domain = validDomain(raw);++ const row = await db.maybe(sql`SELECT * FROM mail_custom_domains WHERE domain=${domain} AND owner_subject=${owner}`);++ if (!row) return null;++ const verified = await db.maybe(sql`SELECT owner_subject FROM mail_custom_domains WHERE domain=${domain} AND ownership_verified_at IS NOT NULL`);++ if (verified && verified.owner_subject !== owner) throw new Error('This domain is already in use on OpenVibe.');++ const ownership = (await txtValues(`_openvibe-mail.${domain}`, lookupTxt)).includes(row.ownership_token);++ const expected = new Set(config.inbound?.mx || []);++ const actualMx = await lookupMx(domain).catch(() => []);++ const mx = expected.size > 0 && actualMx.length > 0 && actualMx.every((record) => expected.has(String(record.exchange || '').toLowerCase().replace(/\.$/,'')));++ let dkim = false;++ if (ownership && mx) {++ const configured = await db.many(sql`SELECT d.provider_id,d.dkim_records,p.* FROM mail_outbound_domains d JOIN mail_outbound_providers p ON p.id=d.provider_id WHERE d.domain=${domain} AND p.enabled=true`);++ for (const provider of configured) {++ const records = (provider.dkim_records || []).filter((r) => r.type === 'TXT' && /_domainkey/i.test(r.name));++ if (!records.length) continue;++ const matches = await Promise.all(records.map(async (record) => (await txtValues(record.name, lookupTxt)).includes(record.value)));++ if (!matches.every(Boolean)) continue;++ let credential = providers.open(provider, providers.keyFrom(config));++ try { credential = JSON.parse(credential); } catch { /* opaque provider token */ }++ const settings = { ...(provider.settings || {}), dkim_hosts: records.map((record) => record.name) };++ try { if (await adapters.domainStatus({ route: { adapter: provider.adapter, settings, password: credential }, domain, fetchImpl, lookupTxt, lookupMx })) { dkim = true; break; } }++ catch { /* provider status unavailable: do not enable sending */ }++ }++ }++ try {++ await db.exec(sql`UPDATE mail_custom_domains SET ownership_verified_at=CASE WHEN ${ownership} THEN COALESCE(ownership_verified_at,now()) ELSE NULL END,++ mx_verified_at=CASE WHEN ${ownership && mx} THEN COALESCE(mx_verified_at,now()) ELSE NULL END,++ dkim_verified_at=CASE WHEN ${ownership && mx && dkim} THEN COALESCE(dkim_verified_at,now()) ELSE NULL END WHERE domain=${domain} AND owner_subject=${owner}`);++ } catch (err) {++ if (err.code === '23505' && err.constraint === 'mail_custom_domains_verified_domain') throw new Error('This domain is already in use on OpenVibe.');++ throw err;++ }++ if (ownership && mx && stalwart?.ensureDomain) await stalwart.ensureDomain(domain);++ return get({ db, config, owner, domain });++}++++module.exports = { add, get, verify, statusOf };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++const crypto = require('node:crypto');++const { sql } = require('openvibe-sdk/db');++const { cleanAddress, senderAllowed, buildMessage } = require('./person-mail');++const { mailboxAddressFor } = require('./mailboxes');++const providers = require('./outbound-providers');++++async function add({ db, owner, source, target, policy }) {++ const to = cleanAddress(target);++ if (!to) throw new Error('Enter a valid forwarding address.');++ const address = source === 'all' || !source ? null : cleanAddress(source);++ if (source && source !== 'all' && !address) throw new Error('Choose one of your addresses or all mail.');++ if (address && !await senderAllowed(db, owner, address)++ && !await db.maybe(sql`SELECT 1 AS ok FROM mail_masks WHERE owner_subject=${owner} AND address=${address} AND enabled=true`)++ && !await db.maybe(sql`SELECT 1 AS ok FROM mail_agent_inboxes WHERE owner_subject=${owner} AND address=${address}`)) throw new Error('Choose one of your active addresses.');++ if (!await policy.verifiedEmail(owner, to)) throw new Error('Verify this address on your Network account before forwarding to it.');++ const mailbox = await mailboxAddressFor(db, owner);++ if (!mailbox) throw new Error('Create your mailbox before adding a forwarding rule.');++ if (to === mailbox || to === address) throw new Error('A forwarding rule cannot send mail back to itself.');++ const existing = await db.maybe(sql`SELECT id FROM mail_forward_rules WHERE owner_subject=${owner} AND source_address IS NOT DISTINCT FROM ${address} AND target_address=${to}`);++ if (existing) return existing.id;++ const id = crypto.randomUUID();++ await db.exec(sql`INSERT INTO mail_forward_rules(id,owner_subject,source_address,target_address) VALUES(${id},${owner},${address},${to})`);++ return id;++}++++async function run({ db, stalwart, config, policy, smtp, fetchImpl = globalThis.fetch, limit = 100 }) {++ if (!config.outbound?.enabled) return { sent: 0 };++ const rules = await db.many(sql`SELECT r.id,r.owner_subject,r.source_address,r.target_address,m.address AS mailbox FROM mail_forward_rules r++ JOIN mail_mailboxes m ON m.owner_subject=r.owner_subject WHERE r.enabled=true ORDER BY r.created_at LIMIT ${limit}`);++ const groups = new Map();++ for (const rule of rules) {++ if (!groups.has(rule.mailbox)) groups.set(rule.mailbox, []);++ groups.get(rule.mailbox).push(rule);++ }++ let sent = 0;++ for (const [mailbox, group] of groups) {++ let page;++ try { page = await stalwart.readMessages({ mailboxAddress: mailbox, limit: 100 }); } catch { continue; }++ for (const row of page.messages || []) {++ if (row.spam) continue;++ const matching = group.filter((rule) => !rule.source_address || rule.source_address === row.to?.[0]);++ if (!matching.length) continue;++ let message;++ try { message = await stalwart.readMessage({ mailboxAddress: mailbox, id: row.id }); } catch { continue; }++ if (!message || message.spam || !message.deliveredTo || (message.attachments || []).length) continue;++ for (const rule of matching) {++ if (rule.source_address && rule.source_address !== message.deliveredTo) continue;++ if (rule.target_address === message.deliveredTo || (message.from || []).some((from) => String(from).toLowerCase().includes(rule.target_address))) continue;++ let verified = false;++ try { verified = await policy.verifiedEmail(rule.owner_subject, rule.target_address); } catch { continue; }++ if (!verified) { await db.exec(sql`UPDATE mail_forward_rules SET enabled=false WHERE id=${rule.id}`); continue; }++ const claimed = await db.maybe(sql`INSERT INTO mail_forward_deliveries(rule_id,message_id,state) VALUES(${rule.id},${message.id},'sending')++ ON CONFLICT(rule_id,message_id) DO UPDATE SET state='sending',updated_at=now()++ WHERE mail_forward_deliveries.state='failed' AND mail_forward_deliveries.updated_at < now()-interval '5 minutes' RETURNING rule_id`);++ if (!claimed) continue;++ const day = new Date().toISOString().slice(0,10);++ const allowance = await db.tx(async (t) => {++ await t.exec(sql`INSERT INTO mail_forward_daily(owner_subject,day) VALUES(${rule.owner_subject},${day}) ON CONFLICT DO NOTHING`);++ const used = await t.maybe(sql`SELECT attempts FROM mail_forward_daily WHERE owner_subject=${rule.owner_subject} AND day=${day} FOR UPDATE`);++ if (Number(used.attempts) >= 100) return false;++ await t.exec(sql`UPDATE mail_forward_daily SET attempts=attempts+1 WHERE owner_subject=${rule.owner_subject} AND day=${day}`);++ return true;++ });++ if (!allowance) { await db.exec(sql`UPDATE mail_forward_deliveries SET state='failed',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`); continue; }++ const body = `Forwarded from ${String(message.from?.[0] || 'unknown sender').slice(0,180)}\n\n${String(message.text || '').slice(0,9000)}`;++ const id = crypto.randomUUID();++ let raw;++ try { raw = buildMessage({ from: mailbox, to: rule.target_address, subject: `Fwd: ${message.subject || '(no subject)'}`.slice(0,200), body, id, reportBase: config.baseUrl, now: new Date() }); }++ catch { await db.exec(sql`UPDATE mail_forward_deliveries SET state='failed',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`); continue; }++ raw = raw.replace('\r\nMIME-Version:', '\r\nAuto-Submitted: auto-forwarded\r\nMIME-Version:');++ let result;++ try { result = await providers.sendWithFailover({ db, config, from: mailbox, to: rule.target_address, message: raw, kind: 'person', smtp, fetchImpl }); }++ catch { continue; }++ if (result.ok) { await db.exec(sql`UPDATE mail_forward_deliveries SET state='sent',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`); sent++; }++ else if (!result.uncertain) await db.exec(sql`UPDATE mail_forward_deliveries SET state='failed',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`);++ }++ }++ }++ return { sent };++}++++module.exports = { add, run };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createTrustClient } = require('openvibe-sdk/trust');++++function createNetworkPolicy(config, fetchImpl = globalThis.fetch) {++ const tokens = config.oauth.clientSecret && createServiceTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.network',++ scope: 'identity.trust.read identity.emails.read', fetch: fetchImpl,++ });++ const getToken = async () => {++ if (!tokens) throw new Error('Mail cannot verify ownership until its Network service credentials are configured.');++ return tokens.getToken();++ };++ const trust = createTrustClient({ networkUrl: config.network.internalUrl, getToken, fetchImpl });++ return {++ trust: (subject) => trust.of(subject),++ async verifiedEmail(subject, email) {++ const response = await fetchImpl(`${config.network.internalUrl}/api/v1/identity/emails/verified-check`, {++ method: 'POST',++ headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ subject, email }), signal: AbortSignal.timeout(5000),++ });++ if (!response.ok) throw new Error('Network could not verify this forwarding address.');++ const body = await response.json();++ return body.verified === true;++ },++ };++}++++module.exports = { createNetworkPolicy };nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++++const crypto = require('node:crypto');++const { sql } = require('openvibe-sdk/db');++const reply = require('./agent-reply');++const sending = require('./sending');++const providers = require('./outbound-providers');++const { ensureOwnerMailbox, mailboxAddressFor } = require('./mailboxes');++++const ADDRESS = /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$/;++const NAME = /^[a-z][a-z0-9-]{2,31}$/;++const BLOCKED = /^(?:admin|abuse|billing|contact|help|info|mail|mailer-daemon|moderator|noreply|postmaster|root|security|support|webmaster|fuck|shit|nazi|hitler)(?:-|$)/i;++++function cleanAddress(value) {++ const address = String(value || '').trim().toLowerCase();++ return address.length <= 254 && ADDRESS.test(address) ? address : null;++}++function validateName(value) {++ const name = String(value || '').trim().toLowerCase();++ if (!NAME.test(name) || BLOCKED.test(name) || name.endsWith('-') || name.includes('--')) throw new Error('Choose a name of 3–32 letters, numbers or hyphens that is not reserved.');++ return name;++}++function dailyLimit(signals, history) {++ if (!signals || signals.kind !== 'account' || !signals.email_verified || Number(signals.account_age_hours) < 24) return 0;++ if (Number(signals.account_age_days) >= 90 && signals.verified && history >= 100) return 100;++ if (Number(signals.account_age_days) >= 30 && signals.verified && history >= 20) return 50;++ return 20;++}++function paused(history) {++ const accepted = history.filter((row) => ['accepted','bounced','complaint'].includes(row.status));++ if (accepted.length < 10) return null;++ const bounced = accepted.filter((row) => row.status === 'bounced').length;++ const complaints = accepted.filter((row) => row.status === 'complaint').length;++ if (complaints / accepted.length >= 0.01) return 'Sending is paused after a complaint. Contact Mail support to review it.';++ if (bounced / accepted.length >= 0.1) return 'Sending is paused after too many bounced messages. Check your recipients and contact Mail support.';++ return null;++}++async function recentHistory(db, owner) {++ return db.many(sql`SELECT status FROM mail_person_sends WHERE owner_subject=${owner} AND created_at > now()-interval '30 days' ORDER BY created_at DESC LIMIT 100`);++}++async function createAddress({ db, config, stalwart, owner, local, domain, kind = 'alias' }) {++ const name = validateName(local);++ const host = String(domain || config.addresses.base).toLowerCase();++ if (!['name','alias','custom'].includes(kind) || (kind === 'custom') !== (host !== config.addresses.base)) throw new Error('Choose an address type that matches its domain.');++ if (host !== config.addresses.base) {++ const ready = await db.maybe(sql`SELECT 1 AS ok FROM mail_custom_domains WHERE domain=${host} AND owner_subject=${owner} AND ownership_verified_at IS NOT NULL AND mx_verified_at IS NOT NULL`);++ if (!ready) throw new Error('Verify domain ownership and MX before creating its address.');++ }++ const address = `${name}@${host}`;++ await db.tx(async (t) => {++ await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });++ if (kind === 'name' && await t.maybe(sql`SELECT 1 AS found FROM mail_person_addresses WHERE owner_subject=${owner} AND kind='name'`)) throw new Error('You already have a named address; use an alias.');++ const count = await t.maybe(sql`SELECT count(*) AS n FROM mail_person_addresses WHERE owner_subject=${owner}`);++ if (Number(count.n) >= 20) throw new Error('You can have up to 20 personal addresses.');++ const existing = await t.maybe(sql`SELECT owner_subject FROM mail_person_addresses WHERE address=${address}`);++ if (existing) { if (existing.owner_subject !== owner) throw new Error('That address is taken.'); return; }++ for (const table of ['mail_mailboxes','mail_masks','mail_burners','mail_agent_inboxes']) {++ const other = await t.maybe(sql`SELECT 1 AS taken FROM ${sql.raw(table)} WHERE address=${address}`);++ if (other) throw new Error('That address is taken.');++ }++ await t.exec(sql`INSERT INTO mail_person_addresses(address,owner_subject,kind) VALUES(${address},${owner},${kind})`);++ });++ if (stalwart && stalwart.invalidateCaches) await stalwart.invalidateCaches();++ return address;++}++++async function senderAllowed(db, owner, address) {++ const mailbox = await mailboxAddressFor(db, owner);++ if (address === mailbox) return true;++ return Boolean(await db.maybe(sql`SELECT 1 AS ok FROM mail_person_addresses p LEFT JOIN mail_custom_domains d ON d.domain=split_part(p.address,'@',2)++ AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL++ WHERE p.owner_subject=${owner} AND p.address=${address} AND p.enabled=true AND (p.kind<>'custom' OR d.dkim_verified_at IS NOT NULL)`));++}++++function buildMessage({ from, to, subject, body, id, reportBase, now }) {++ const title = reply.headerValue(subject);++ if (title === null || !title || title.length > 200) throw new Error('A subject of at most 200 characters is required.');++ const text = String(body || '');++ if (!text.trim() || text.length > reply.MAX_BODY) throw new Error('A message body of at most 10,000 characters is required.');++ const headers = [`From: ${from}`, `To: ${to}`, `Subject: ${title}`, `Date: ${reply.rfcDate(now)}`,++ `Message-ID: <${reply.newMessageId(from.split('@')[1])}>`, `X-OpenVibe-Report-Abuse: ${reportBase}/report/${id}`,++ 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: 8bit'];++ return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;++}++++async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch }) {++ const sender = cleanAddress(from);++ const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];++ if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };++ try { buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }++ catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }++ if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };++ let signals;++ try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }++ const history = await recentHistory(db, owner);++ const limit = dailyLimit(signals, history.filter((r) => r.status === 'accepted').length);++ if (!limit) return { status: 403, code: 'mail.send.trust', detail: 'Verify your account email and wait until your account is one day old before sending.' };++ const pause = paused(history);++ if (pause) return { status: 403, code: 'mail.send.paused', detail: pause };++ const day = now.toISOString().slice(0,10);++ const reserved = await db.tx(async (t) => {++ await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);++ await t.exec(sql`INSERT INTO mail_person_daily(owner_subject,day) VALUES(${owner},${day}) ON CONFLICT DO NOTHING`);++ const row = await t.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day} FOR UPDATE`);++ if (Number(row.attempts) + targets.length > limit) return false;++ await t.exec(sql`UPDATE mail_person_daily SET attempts=attempts+${targets.length} WHERE owner_subject=${owner} AND day=${day}`);++ return true;++ });++ if (!reserved) return { status: 429, code: 'mail.send.daily_limit', detail: `Your daily allowance is ${limit} recipients. It grows with account history.` };++ const mailer = smtp || require('../stalwart/smtp');++ const results = [];++ for (const to of targets) {++ const id = crypto.randomUUID();++ await db.exec(sql`INSERT INTO mail_person_sends(id,owner_subject,sender,recipient,status) VALUES(${id},${owner},${sender},${to},'sending')`);++ let message;++ try { message = buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }++ catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }++ let result;++ const route = sending.routeFor(to, config);++ try {++ if (sending.isLocal(to, config)) result = await mailer.sendMessage(route, { from: sender, to, message });++ else if (!config.outbound.enabled) result = { ok: false, reason: sending.OUTBOUND_CLOSED };++ else result = await providers.sendWithFailover({ db, config, from: sender, to, message, kind: 'person', smtp: mailer, fetchImpl, now });++ } catch { result = { ok: false, uncertain: true, reason: 'The provider response was uncertain.' }; }++ const state = result.ok ? 'accepted' : result.uncertain ? 'uncertain' : 'failed';++ await db.exec(sql`UPDATE mail_person_sends SET status=${state},provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);++ if (result.ok) await db.exec(sql`UPDATE mail_person_daily SET accepted=accepted+1 WHERE owner_subject=${owner} AND day=${day}`);++ results.push({ id, to, status: state, reason: result.ok ? null : String(result.reason || 'The provider refused this message.').slice(0,180) });++ }++ return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };++}++++module.exports = { cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };@@@ -110,7 +110,8 @@ const apiAdapters = {// AWS SES v2 SendEmail API and SigV4: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.htmlses: {async send(ctx) {-- const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Simple: { Subject: { Data: ctx.message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: ctx.message.text, Charset: 'UTF-8' } } } } };++ const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Simple: { Subject: { Data: ctx.message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: ctx.message.text, Charset: 'UTF-8' } },++ ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) } } };const raw = JSON.stringify(body); const signed = sesAuthorization(c, raw);const result = await post(ctx.fetch, `https://${signed.host}/v2/email/outbound-emails`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }, body);return { messageId: result.MessageId || null };@@@ -124,7 +125,7 @@ const apiAdapters = {},// Brevo transactional send: https://developers.brevo.com/reference/send-transac-emailbrevo: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text }); return { messageId: data.messageId || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.messageId || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.brevo.com/v3/senders?limit=100', { 'api-key': ctx.credentials }); return (data.senders || []).some((s) => s.domain === domain && s.active === true); },},// Mailjet Send API v3.1: https://dev.mailjet.com/email/guides/send-api-v31/@@@ -139,7 +140,7 @@ const apiAdapters = {},// Resend send email: https://resend.com/docs/api-reference/emails/send-emailresend: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text }); return { messageId: data.id || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.id || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.resend.com/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.status === 'verified'); },},// SMTP2GO Send Email: https://developers.smtp2go.com/docs/send-an-email . Verified sender APIs: https://developers.smtp2go.com/docs/getting-started@@@ -154,7 +155,7 @@ const apiAdapters = {},// Postmark Email API: https://postmarkapp.com/developer/api/email-apipostmark: {-- async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, MessageStream: ctx.settings.message_stream || 'outbound' }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, MessageStream: ctx.settings.message_stream || 'outbound', ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.postmarkapp.com/senders', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }); return (data.SenderSignatures || []).some((s) => String(s.FromEmail || '').toLowerCase().endsWith(`@${domain}`) && s.Confirmed === true && s.DKIMVerified === true); },},};@@@ -174,7 +175,8 @@ function normalizeWebhook(adapter, payload) {const recipient = String(sesRecipient || root.email || root.Email || root.recipient || root.recipient_email || root.Recipient || root.To || root.to || nested.email || nested.Email || nested.recipient || nested.recipient_email || nested.Recipient || nested.To || nested.to || nested.address || firstRecipient?.email || firstRecipient?.Email || firstRecipient || '').trim().toLowerCase();const soft = /soft.?bounc|deferred|temporary|transient/.test(event);const hard = /hard.?bounc|permanent.?bounc|undeliverable|spam.?complaint|complaint|blocked/.test(event) || (!soft && /bounc(?:e|ed)/.test(event));-- return { adapter, event, recipient, kind: /(complaint|spam)/.test(event) ? 'complaint' : soft ? 'soft_bounce' : hard ? 'hard_bounce' : /delivered|delivery/.test(event) ? 'delivered' : 'unknown' };++ const messageId = String(root.mail?.messageId || root.MessageID || root.MessageId || root.message_id || nested.email_id || nested.message_id || nested.messageId || '').trim() || null;++ return { adapter, event, recipient, messageId, kind: /(complaint|spam)/.test(event) ? 'complaint' : soft ? 'soft_bounce' : hard ? 'hard_bounce' : /delivered|delivery/.test(event) ? 'delivered' : 'unknown' };}function adapterFor(name) {@@@ -112,6 +112,10 @@ async function ingest({ db, provider, payload, now = new Date() }) {const inserted = await t.maybe(sql`INSERT INTO mail_outbound_webhook_events(provider_id,event_id,recipient,recipient_domain,kind)VALUES (${provider.id},${id},${address},${recipientDomain},${normalized.kind}) ON CONFLICT DO NOTHING RETURNING event_id`);if (!inserted) return { accepted: true, duplicate: true };++ if (normalized.messageId && (normalized.kind === 'hard_bounce' || normalized.kind === 'complaint')) {++ await t.exec(sql`UPDATE mail_person_sends SET status=${normalized.kind === 'complaint' ? 'complaint' : 'bounced'}++ WHERE provider_id=${provider.id} AND provider_message_id=${normalized.messageId} AND recipient=${address} AND status='accepted'`);++ }if (normalized.kind === 'hard_bounce' || normalized.kind === 'complaint') {await t.exec(sql`INSERT INTO mail_outbound_suppressions(address,reason,source_provider) VALUES (${address},${normalized.kind},${provider.id})ON CONFLICT(address) DO UPDATE SET reason=CASE WHEN mail_outbound_suppressions.reason='complaint' THEN 'complaint' ELSE excluded.reason END`);@@@ -328,6 +328,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt// Reading your own mail (M1.5): server/http/reading-routes.js.require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });++ require('./person-routes').mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl });// Inbox AI pulling another provider's mailbox over IMAP: server/http/imap-routes.js.require('./imap-routes').mountImap(router, { db, config, principal, wrap, fail, ownerOf, ...(imap ? { imap } : {}) });nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++const { sql } = require('openvibe-sdk/db');++const addresses = require('../domain/person-mail');++const customDomains = require('../domain/custom-domains');++const forwarding = require('../domain/forwarding');++const { createNetworkPolicy } = require('../domain/network-policy');++++function mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl }) {++ const read = principal.requirePerson(principal.CAPABILITIES.read);++ const write = principal.requirePerson(principal.CAPABILITIES.write);++ const policy = createNetworkPolicy(config, fetchImpl);++ const noStore = (res) => res.set('Cache-Control', 'private, no-store');++ const humanOnly = (req, res, next) => req.principal?.kind === 'user' ? next()++ : fail(req, res, 403, 'mail.person_required', 'Sign in as the account owner to send or forward mail.');++++ router.get('/api/v1/me/mail/person-addresses', read, wrap(async (req, res) => {++ const rows = await db.many(sql`SELECT address,kind,enabled,created_at FROM mail_person_addresses WHERE owner_subject=${ownerOf(req)} ORDER BY created_at`);++ return noStore(res).json({ addresses: rows });++ }));++ router.post('/api/v1/me/mail/person-addresses', write, wrap(async (req, res) => {++ const kind = req.body?.kind === 'name' ? 'name' : req.body?.kind === 'custom' ? 'custom' : 'alias';++ try {++ const address = await addresses.createAddress({ db, config, stalwart, owner: ownerOf(req), local: req.body?.name, domain: req.body?.domain, kind });++ return noStore(res).status(201).json({ address, kind });++ } catch (err) { return fail(req, res, 422, 'mail.address.invalid', err.message); }++ }));++++ router.get('/api/v1/me/mail/custom-domains', read, wrap(async (req, res) => {++ const rows = await db.many(sql`SELECT domain FROM mail_custom_domains WHERE owner_subject=${ownerOf(req)} ORDER BY created_at`);++ return noStore(res).json({ domains: await Promise.all(rows.map((row) => customDomains.get({ db, config, owner: ownerOf(req), domain: row.domain }))) });++ }));++ router.post('/api/v1/me/mail/custom-domains', write, wrap(async (req, res) => {++ try { return noStore(res).status(201).json(await customDomains.add({ db, config, owner: ownerOf(req), domain: req.body?.domain })); }++ catch (err) { return fail(req, res, 422, 'mail.custom_domain.invalid', err.message); }++ }));++ router.post('/api/v1/me/mail/custom-domains/:domain/verify', write, wrap(async (req, res) => {++ try {++ const result = await customDomains.verify({ db, config, stalwart, owner: ownerOf(req), domain: req.params.domain, fetchImpl });++ if (!result) return fail(req, res, 404, 'mail.custom_domain.not_found', 'That domain is not yours.');++ return noStore(res).json(result);++ } catch (err) { return fail(req, res, 422, 'mail.custom_domain.invalid', err.message); }++ }));++++ router.post('/api/v1/me/mail/send', write, humanOnly, wrap(async (req, res) => {++ const result = await addresses.send({ db, config, owner: ownerOf(req), from: req.body?.from,++ recipients: req.body?.to, subject: req.body?.subject, body: req.body?.body, policy, smtp, fetchImpl });++ if (result.code) return fail(req, res, result.status, result.code, result.detail);++ return noStore(res).status(result.status).json(result);++ }));++++ router.get('/api/v1/me/mail/forwards', read, wrap(async (req, res) => {++ const rows = await db.many(sql`SELECT id,source_address,target_address,enabled,created_at FROM mail_forward_rules WHERE owner_subject=${ownerOf(req)} ORDER BY created_at`);++ return noStore(res).json({ forwards: rows });++ }));++ router.post('/api/v1/me/mail/forwards', write, humanOnly, wrap(async (req, res) => {++ try {++ const id = await forwarding.add({ db, owner: ownerOf(req), source: req.body?.source, target: req.body?.target, policy });++ return noStore(res).status(201).json({ id });++ } catch (err) { return fail(req, res, 422, 'mail.forward.invalid', err.message); }++ }));++ router.delete('/api/v1/me/mail/forwards/:id', write, humanOnly, wrap(async (req, res) => {++ const row = await db.maybe(sql`DELETE FROM mail_forward_rules WHERE id=${req.params.id} AND owner_subject=${ownerOf(req)} RETURNING id`);++ if (!row) return fail(req, res, 404, 'mail.forward.not_found', 'That forwarding rule is not yours.');++ return noStore(res).status(204).end();++ }));++++ router.get('/report/:id', (req, res) => {++ if (!/^[0-9a-f-]{36}$/.test(req.params.id)) return res.status(404).end();++ res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-store');++ return res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>Report a message</title><main><h1>Report an OpenVibe message</h1><p>Use this if this message was unwanted. Your report pauses sending when abuse is detected.</p><form method="post" action="/api/v1/mail/report/${req.params.id}"><button type="submit">Report message</button></form></main></html>`);++ });++ router.post('/api/v1/mail/report/:id', wrap(async (req, res) => {++ if (!/^[0-9a-f-]{36}$/.test(req.params.id)) return fail(req, res, 404, 'mail.report.not_found', 'The message was not found.');++ const row = await db.maybe(sql`INSERT INTO mail_person_reports(send_id) SELECT id FROM mail_person_sends WHERE id=${req.params.id} AND status IN ('accepted','complaint') ON CONFLICT DO NOTHING RETURNING send_id`);++ if (row) await db.exec(sql`UPDATE mail_person_sends SET status='complaint' WHERE id=${row.send_id}`);++ return noStore(res).json({ received: true });++ }));++}++++module.exports = { mountPersonMail };@@@ -37,6 +37,10 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, faiif (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 });++ const people = await db.many(sql`SELECT p.address,p.kind FROM mail_person_addresses p WHERE p.owner_subject=${owner} AND p.enabled=true++ AND (p.kind<>'custom' OR EXISTS (SELECT 1 FROM mail_custom_domains d WHERE d.domain=split_part(p.address,'@',2)++ AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL AND d.mx_verified_at IS NOT NULL)) ORDER BY p.created_at`);++ for (const p of people) list.push({ address: p.address, kind: p.kind, label: null });return { mailbox, list };}@@@ -16,6 +16,7 @@ const { startPurgeJob } = require('./jobs/purge');const { startAgentMailJob } = require('./jobs/agent-mail');const { startInboxAiJob } = require('./jobs/inbox-ai');const { startImapPullJob } = require('./jobs/imap-pull');++const { startForwardingJob } = require('./jobs/forwarding');async function main() {const config = configLib.load();@@@ -56,6 +57,7 @@ async function main() {// Inbox AI's other inbox: a connected mailbox is read when its own next_sync_at is due. Without MAIL_SECRETS_KEY// there is nothing to open, and the job is a no-op.const imapPull = startImapPullJob({ db, config, log });++ const forwarding = startForwardingJob({ db, stalwart, config, log });const { app } = await createApp({ config, store, stalwart, events, log });@@@ -69,6 +71,7 @@ async function main() {agentMail.stop();inboxAi.stop();imapPull.stop();++ forwarding.stop();events.stop();server.close(() => {store.close().finally(() => process.exit(0));@@@ -78,7 +81,7 @@ async function main() {process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));-- return { server, store, stalwart, events, purge, agentMail, inboxAi, imapPull };++ return { server, store, stalwart, events, purge, agentMail, inboxAi, imapPull, forwarding };}if (require.main === module) {nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++++const { createNetworkPolicy } = require('../domain/network-policy');++const { run } = require('../domain/forwarding');++++function startForwardingJob({ db, stalwart, config, log = console, fetchImpl = globalThis.fetch, intervalMs = 60_000 }) {++ const policy = createNetworkPolicy(config, fetchImpl);++ let timer;++ let stopped = false;++ async function tick() {++ if (stopped) return;++ try { await run({ db, stalwart, config, policy, fetchImpl }); }++ catch { (log.warn || log.log).call(log, '[mail] forwarding run failed'); }++ if (!stopped) { timer = setTimeout(tick, intervalMs); timer.unref?.(); }++ }++ timer = setTimeout(tick, intervalMs);++ timer.unref?.();++ return { stop() { stopped = true; clearTimeout(timer); }, runNow: tick };++}++++module.exports = { startForwardingJob };@@@ -19,13 +19,15 @@ async function startNetwork() {const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });-- const st = { users: new Map(), requests: [], down: false };++ const st = { users: new Map(), requests: [], down: false, trust: new Map(), verifiedEmails: new Map() };let issuer = null;let nextUserId = 1;-- function addUser(username, { role = 'user' } = {}) {++ function addUser(username, { role = 'user', ageDays = 90, emailVerified = true, verifiedEmails = [] } = {}) {const u = { id: nextUserId++, subject: ids.newId('user'), username, role };st.users.set(u.id, u);++ st.trust.set(u.subject, { kind: 'account', account_age_hours: ageDays * 24, account_age_days: ageDays, email_verified: emailVerified, verified: ageDays >= 30 });++ st.verifiedEmails.set(u.subject, new Set(verifiedEmails.map((email) => email.toLowerCase())));return u;}@@@ -63,6 +65,15 @@ async function startNetwork() {const url = new URL(req.url, 'http://x');if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });if (st.down) return json(503, { error: 'network is restarting' });++ if (url.pathname === '/oauth/token' && req.method === 'POST') return json(200, { access_token: 'test-network-service', expires_in: 300, scope: 'identity.trust.read identity.emails.read' });++ if (req.headers.authorization === 'Bearer test-network-service' && url.pathname.startsWith('/api/v1/trust/')) {++ const trust = st.trust.get(decodeURIComponent(url.pathname.slice('/api/v1/trust/'.length)));++ return json(trust ? 200 : 404, trust || { error: 'not found' });++ }++ if (req.headers.authorization === 'Bearer test-network-service' && url.pathname === '/api/v1/identity/emails/verified-check' && req.method === 'POST') {++ let input = {}; try { input = JSON.parse(raw.toString('utf8')); } catch { /* invalid means unverified */ }++ return json(200, { verified: !!st.verifiedEmails.get(input.subject)?.has(String(input.email || '').toLowerCase()) });++ }return json(404, { error: 'not found' });});});nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++++const test = require('node:test');++const assert = require('node:assert/strict');++const { boot } = require('./helpers/boot');++const person = require('../server/domain/person-mail');++const domains = require('../server/domain/custom-domains');++const providers = require('../server/domain/outbound-providers');++const providerDomains = require('../server/domain/provider-domains');++const { sql } = require('openvibe-sdk/db');++++test('daily allowance grows with trusted history and pauses on bounce or complaint rates', () => {++ const signals = { kind: 'account', email_verified: true, verified: true, account_age_hours: 90*24, account_age_days: 90 };++ assert.equal(person.dailyLimit({ ...signals, account_age_hours: 23 }, 100), 0);++ assert.equal(person.dailyLimit({ ...signals, email_verified: false }, 100), 0);++ assert.equal(person.dailyLimit({ ...signals, account_age_days: 3 }, 0), 20);++ assert.equal(person.dailyLimit({ ...signals, account_age_days: 30 }, 20), 50);++ assert.equal(person.dailyLimit(signals, 100), 100);++ assert.match(person.paused([{ status: 'complaint' }, ...Array(9).fill({ status: 'accepted' })]), /complaint/);++ assert.match(person.paused([{ status: 'bounced' }, ...Array(9).fill({ status: 'accepted' })]), /bounced/);++ assert.equal(person.paused(Array(9).fill({ status: 'accepted' })), null);++});++++test('person send checks trust, recipient cap and concurrent daily allowance', async (t) => {++ const smtp = { sendMessage: async () => ({ ok: true, code: 250 }) };++ const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' }, smtp });++ t.after(() => s.close());++ const alice = s.network.addUser('alice', { ageDays: 3 });++ const made = await s.post('/api/v1/me/mail/person-addresses', { as: alice, json: { name: 'alice', kind: 'name' } });++ assert.equal(made.status, 201, made.text);++ const appToken = s.network.signJwt({ iss: s.network.url, sub: 'svc:mail-test', actor_type: 'service',++ aud: ['openvibe.mail'], cap: ['mail.addresses.write'], on_behalf_of: alice.subject,++ iat: Math.floor(Date.now()/1000), exp: Math.floor(Date.now()/1000)+300, jti: 'person-mail-app-1' });++ assert.equal((await s.post('/api/v1/me/mail/send', { bearer: appToken, json: { from: made.body.address, to: ['[email protected]'], subject: 'Hello', body: 'Hi' } })).status, 403);++ const send = (to) => s.post('/api/v1/me/mail/send', { as: alice, json: { from: made.body.address, to, subject: 'Hello', body: 'One message.' } });++ assert.equal((await send(Array.from({ length: 11 }, (_, i) => `r${i}@openvibe.email`))).status, 422);++ assert.equal((await send(Array.from({ length: 10 }, (_, i) => `a${i}@openvibe.email`))).status, 200);++ assert.equal((await send(Array.from({ length: 10 }, (_, i) => `b${i}@openvibe.email`))).status, 200);++ const over = await send(['[email protected]']);++ assert.equal(over.status, 429, over.text);++ assert.match(over.body.detail, /20/);++});++++test('forwarding refuses unverified targets and accepts only Network verified account emails', async (t) => {++ const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' } });++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const mask = await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });++ assert.equal(mask.status, 201);++ const unverified = await s.post('/api/v1/me/mail/forwards', { as: alice, json: { source: mask.body.mask.address, target: '[email protected]' } });++ assert.equal(unverified.status, 422);++ s.network.state.verifiedEmails.get(alice.subject).add('[email protected]');++ const verified = await s.post('/api/v1/me/mail/forwards', { as: alice, json: { source: mask.body.mask.address, target: '[email protected]' } });++ assert.equal(verified.status, 201, verified.text);++ const rows = await s.get('/api/v1/me/mail/forwards', { as: alice });++ assert.equal(rows.body.forwards.length, 1);++});++++test('custom domain advances only after ownership, MX and DKIM are verified', async (t) => {++ const s = await boot({ env: { MAIL_CUSTOM_DOMAIN_MX: 'mail.openvibe.email', MAIL_PROVIDER_KEK: Buffer.alloc(32, 3).toString('base64') } });++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });++ const added = await domains.add({ db: s.db, config: s.config, owner: alice.subject, domain: 'alice.example' });++ assert.equal(added.status, 'pending_ownership');++ const token = added.dns.ownership.value;++ const lookupTxt = async (host) => host === '_openvibe-mail.alice.example' ? [[token]] : host === 's1._domainkey.alice.example' ? [['v=DKIM1; p=test']] : [];++ const lookupMx = async () => [{ exchange: 'mail.openvibe.email', priority: 10 }];++ let checked = await domains.verify({ db: s.db, config: s.config, owner: alice.subject, domain: 'alice.example', lookupTxt: async () => [], lookupMx });++ assert.equal(checked.status, 'pending_ownership');++ checked = await domains.verify({ db: s.db, config: s.config, owner: alice.subject, domain: 'alice.example', lookupTxt, lookupMx: async () => [] });++ assert.equal(checked.status, 'pending_mx');++ await providers.save({ db: s.db, config: s.config, input: { id: 'smtp_domain', name: 'SMTP', host: 'smtp.example', enabled: true, free_monthly: 100, monthly_budget: 1, password: 'secret' } });++ await providerDomains.save({ db: s.db, providerId: 'smtp_domain', input: { domain: 'alice.example', dkim_records: [{ type: 'TXT', name: 's1._domainkey.alice.example', value: 'v=DKIM1; p=test' }] } });++ checked = await domains.verify({ db: s.db, config: s.config, stalwart: { ensureDomain: async () => true }, owner: alice.subject, domain: 'alice.example', lookupTxt, lookupMx });++ assert.equal(checked.status, 'active', JSON.stringify(checked));++});++++test('pending custom domains cannot squat on another owner or receive their mail', async (t) => {++ const s = await boot({ env: { MAIL_CUSTOM_DOMAIN_MX: 'mail.openvibe.email' } });++ t.after(() => s.close());++ const alice = s.network.addUser('alice');++ const bob = s.network.addUser('bob');++ const claim = (owner, domain) => domains.add({ db: s.db, config: s.config, owner: owner.subject, domain });++++ const squatter = await claim(alice, 'gmail.com');++ const other = await claim(bob, 'gmail.com');++ assert.equal(squatter.status, 'pending_ownership');++ assert.equal(other.status, 'pending_ownership');++ assert.notEqual(squatter.dns.ownership.value, other.dns.ownership.value);++++ const first = await claim(alice, 'shared.example');++ const second = await claim(bob, 'shared.example');++ assert.notEqual(first.dns.ownership.value, second.dns.ownership.value);++ assert.equal((await claim(alice, 'shared.example')).dns.ownership.value, first.dns.ownership.value);++ assert.equal(await s.db.value('SELECT count(*)::int FROM mail_custom_domains WHERE domain=$1', ['shared.example']), 2);++++ // A stale or forged pending address must not enter Stalwart's inbound directory.++ await s.db.exec(sql`INSERT INTO mail_person_addresses(address,owner_subject,kind) VALUES('[email protected]',${bob.subject},'custom')`);++ assert.equal(await s.db.value('SELECT count(*)::int FROM mail_directory_accounts WHERE address=$1', ['[email protected]']), 0);++++ const dns = { lookupTxt: async () => [[first.dns.ownership.value], [second.dns.ownership.value]],++ lookupMx: async () => [{ exchange: 'mail.openvibe.email', priority: 10 }] };++ const verified = await domains.verify({ db: s.db, config: s.config, owner: alice.subject, domain: 'shared.example', ...dns });++ assert.equal(verified.status, 'pending_dkim');++ const address = await person.createAddress({ db: s.db, config: s.config, owner: alice.subject, local: 'alice', domain: 'shared.example', kind: 'custom' });++ assert.equal(await s.db.value('SELECT owner FROM mail_directory_accounts WHERE address=$1', [address]), alice.subject);++ assert.equal(await s.db.value('SELECT count(*)::int FROM mail_directory_accounts WHERE address=$1', ['[email protected]']), 0);++++ await assert.rejects(domains.verify({ db: s.db, config: s.config, owner: bob.subject, domain: 'shared.example', ...dns }),++ { message: 'This domain is already in use on OpenVibe.' });++ await assert.rejects(claim(bob, 'shared.example'), { message: 'This domain is already in use on OpenVibe.' });++ await assert.rejects(s.db.exec(sql`UPDATE mail_custom_domains SET ownership_verified_at=now() WHERE domain='shared.example' AND owner_subject=${bob.subject}`),++ (err) => err.code === '23505' && err.constraint === 'mail_custom_domains_verified_domain');++ assert.equal(await s.db.value('SELECT owner_subject FROM mail_custom_domains WHERE domain=$1 AND ownership_verified_at IS NOT NULL', ['shared.example']), alice.subject);++ await s.db.exec(sql`UPDATE mail_custom_domains SET dkim_verified_at=now() WHERE domain='shared.example' AND owner_subject=${alice.subject}`);++ assert.equal(await person.senderAllowed(s.db, alice.subject, address), true);++ assert.equal(await person.senderAllowed(s.db, bob.subject, '[email protected]'), false);++});@@@ -75,10 +75,11 @@ test('domain status is normalized and SES signatures cover SigV4 canonical data'assert.match(signed.authorization, /SignedHeaders=content-type;host;x-amz-date/);assert.equal(normalizeWebhook('resend', { type: 'email.bounced', to: '[email protected]' }).kind, 'hard_bounce');assert.deepEqual(normalizeWebhook('ses', { notificationType: 'Bounce', Bounce: { bouncedRecipients: [{ emailAddress: '[email protected]' }] } }), {-- adapter: 'ses', event: 'bounce', recipient: '[email protected]', kind: 'hard_bounce',++ adapter: 'ses', event: 'bounce', recipient: '[email protected]', messageId: null, kind: 'hard_bounce',});assert.deepEqual(normalizeWebhook('mailersend', { type: 'activity.spam_complaint', data: { recipient: '[email protected]' } }), {-- adapter: 'mailersend', event: 'activity.spam_complaint', recipient: '[email protected]', kind: 'complaint',++ adapter: 'mailersend', event: 'activity.spam_complaint', recipient: '[email protected]', messageId: null, kind: 'complaint',});++ assert.equal(normalizeWebhook('resend', { type: 'email.bounced', data: { to: '[email protected]', email_id: 'provider-message' } }).messageId, 'provider-message');assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');});
@@ -86,6 +86,9 @@ MAIL_EVENTS_CLIENT_SECRET=
@@ -86,6 +86,9 @@ MAIL_EVENTS_CLIENT_SECRET=
# reply to an outside address opens no connection at all and the agent's system turns it into the person's approval.
# reply to an outside address opens no connection at all and the agent's system turns it into the person's approval.
MAIL_SMTP_HOST=127.0.0.1
MAIL_SMTP_HOST=127.0.0.1
MAIL_SMTP_PORT=25
MAIL_SMTP_PORT=25
+# MX target for a person's custom domain. Set to the hostname that hands inbound SMTP to this Stalwart instance.
+# A custom domain cannot pass MX verification while this is empty.
+#MAIL_CUSTOM_DOMAIN_MX=mail.openvibe.email
#MAIL_OUTBOUND=off
#MAIL_OUTBOUND=off
#MAIL_RELAY_HOST=
#MAIL_RELAY_HOST=
#MAIL_RELAY_PORT=587
#MAIL_RELAY_PORT=587
@@ -7,6 +7,21 @@ mailbox over IMAP (TLS only, read only, app password sealed with `MAIL_SECRETS_K
@@ -7,6 +7,21 @@ mailbox over IMAP (TLS only, read only, app password sealed with `MAIL_SECRETS_K
person edits and clicks Send; external delivery remains closed unless the configured outbound relay is enabled. See
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.
[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)
## 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
`/inbox` on openvibe.email shows a signed-in person's mailbox and the addresses that deliver into it (masks, agent
@@ -1,5 +1,20 @@
@@ -1,5 +1,20 @@
# DNS for M1 (receive only)
# DNS for M1 (receive only)
+## Person-owned custom domains
+
+Add the domain in `/inbox`. Mail gives the owner a unique `_openvibe-mail.<domain>` TXT value and the required
+MX, provider DKIM, and SPF records. Set `MAIL_CUSTOM_DOMAIN_MX` to the hostname that delivers SMTP to this
+Stalwart instance (for the current direct inbound route, `mail.openvibe.email`). The owner adds these records at
+their DNS provider and clicks **Check DNS**. Mail moves through `pending_ownership`, `pending_mx`, `pending_dkim`,
+then `active`; it registers the domain in Stalwart only after TXT and MX match. The SQL directory then maps each
+claimed custom address to the person's existing mailbox. Sending as the custom domain requires at least one
+enabled outbound provider with matching DKIM DNS and a verified domain status. Rechecking removes sending status
+if DNS is withdrawn.
+
+Cloudflare DNS can host these records. A domain using Cloudflare Email Routing needs its own routing rule to deliver
+to the Stalwart inbound hostname; configuring DNS alone does not create that rule. Keep `MAIL_CUSTOM_DOMAIN_MX`
+matched to the actual receiving route. Mail never creates a Cloudflare account or zone for the owner.
+
M1 receives for `openvibe.email`, `mask.openvibe.email` and each burner subdomain in `BURNER_DOMAINS`. It does not
M1 receives for `openvibe.email`, `mask.openvibe.email` and each burner subdomain in `BURNER_DOMAINS`. It does not
send: until M2 there is no DKIM key and no sending path, so SPF and DMARC are strict and closed.
send: until M2 there is no DKIM key and no sending path, so SPF and DMARC are strict and closed.
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+-- phase: expand
+-- Person-owned addresses, custom domains, outbound allowance and verified forwarding.
+CREATE TABLE IF NOT EXISTS mail_custom_domains (
+ domain text NOT NULL,
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ ownership_token text NOT NULL,
+ ownership_verified_at timestamptz,
+ mx_verified_at timestamptz,
+ dkim_verified_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (domain, owner_subject)
+);
+CREATE INDEX IF NOT EXISTS mail_custom_domains_owner ON mail_custom_domains(owner_subject);
+CREATE UNIQUE INDEX IF NOT EXISTS mail_custom_domains_verified_domain ON mail_custom_domains(domain) WHERE ownership_verified_at IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS mail_person_addresses (
+ address text PRIMARY KEY,
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ kind text NOT NULL CHECK (kind IN ('name','alias','custom')),
+ enabled boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mail_person_addresses_owner ON mail_person_addresses(owner_subject,created_at);
+
+CREATE TABLE IF NOT EXISTS mail_person_daily (
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ day date NOT NULL,
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
+ accepted integer NOT NULL DEFAULT 0 CHECK (accepted >= 0),
+ PRIMARY KEY(owner_subject,day)
+);
+CREATE TABLE IF NOT EXISTS mail_person_sends (
+ id text PRIMARY KEY,
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ sender text NOT NULL,
+ recipient text NOT NULL,
+ provider_id text,
+ provider_message_id text,
+ status text NOT NULL CHECK(status IN ('sending','accepted','failed','uncertain','bounced','complaint')),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS mail_person_sends_owner ON mail_person_sends(owner_subject,created_at DESC);
+CREATE INDEX IF NOT EXISTS mail_person_sends_provider_message ON mail_person_sends(provider_id,provider_message_id) WHERE provider_message_id IS NOT NULL;
+CREATE TABLE IF NOT EXISTS mail_person_reports (
+ send_id text PRIMARY KEY REFERENCES mail_person_sends(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS mail_forward_rules (
+ id text PRIMARY KEY,
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ source_address text,
+ target_address text NOT NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE(owner_subject,source_address,target_address)
+);
+CREATE INDEX IF NOT EXISTS mail_forward_rules_owner ON mail_forward_rules(owner_subject);
+CREATE TABLE IF NOT EXISTS mail_forward_daily (
+ owner_subject text NOT NULL REFERENCES mail_mailboxes(owner_subject) ON DELETE CASCADE,
+ day date NOT NULL,
+ attempts integer NOT NULL DEFAULT 0 CHECK(attempts >= 0),
+ PRIMARY KEY(owner_subject,day)
+);
+CREATE TABLE IF NOT EXISTS mail_forward_deliveries (
+ rule_id text NOT NULL REFERENCES mail_forward_rules(id) ON DELETE CASCADE,
+ message_id text NOT NULL,
+ state text NOT NULL CHECK(state IN ('sending','sent','failed')),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY(rule_id,message_id)
+);
+
+CREATE OR REPLACE VIEW mail_directory_accounts AS
+ SELECT mb.address,mb.address AS name,mb.owner_subject AS owner,''::text AS secret,'mailbox'::text AS description FROM mail_mailboxes mb
+ UNION ALL SELECT m.address,mb.address,m.owner_subject,''::text,'mask'::text FROM mail_masks m JOIN mail_mailboxes mb USING(owner_subject) WHERE m.enabled
+ UNION ALL SELECT b.address,b.address,b.owner_subject,''::text,'burner'::text FROM mail_burners b WHERE b.expires_at > now() AND (NOT b.first_message_only OR b.first_message_at IS NULL)
+ UNION ALL SELECT a.address,mb.address,a.owner_subject,''::text,'agent inbox'::text FROM mail_agent_inboxes a JOIN mail_mailboxes mb USING(owner_subject)
+ UNION ALL SELECT p.address,mb.address,p.owner_subject,''::text,'person address'::text FROM mail_person_addresses p JOIN mail_mailboxes mb USING(owner_subject)
+ WHERE p.enabled AND (p.kind <> 'custom' OR EXISTS (
+ SELECT 1 FROM mail_custom_domains d WHERE d.domain=split_part(p.address,'@',2)
+ AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL AND d.mx_verified_at IS NOT NULL));
@@ -14,8 +14,8 @@
@@ -14,8 +14,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"imapflow": "2.11.0",
"imapflow": "2.11.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sanitize-html": "^2.18.1"
"sanitize-html": "^2.18.1"
@@ -1259,9 +1259,9 @@
@@ -1259,9 +1259,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.342.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
- "integrity": "sha512-gSHwLxPVYSkPN2bZScIlyG6KhxjnKdAIjRomBhm/XngrgfboZR58rfvUG9iLV2ekJPRcX5UxvABL+B/9vtLFkg==",
+ "version": "0.349.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
+ "integrity": "sha512-SatU6LdZ1giDM6aszhoqYcZGmk+94V3Tg9FlacaWgDZZNIrxDbYmiz32CKPTsuLC9W+fgQxQcRw4Si8+OOsHMQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1275,9 +1275,9 @@
@@ -1275,9 +1275,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.97.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
- "integrity": "sha512-zCRmu7NoOi7kI0v04N8uypCgsuEcf9kCzkw88r0+dj9PG3AA/z+Pp4BZaX6Z1JUE3sdrtc17D5LcD05IbWsw8w==",
+ "version": "0.102.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
+ "integrity": "sha512-ItPOSRFvXIdFkVRF4/eDO0RUG0GJkJmfzebG5vO6WofYEVvWUdPok0VwKK5fgoOhbnZLoWWKoRCiiNG52/neaA==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-mail",
"name": "openvibe-mail",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.Mail \u2014 openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",
+ "description": "OpenVibe.Mail — openvibe.email: masks that protect you, burners that self-destruct, and an inbox for every agent.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -28,8 +28,8 @@
@@ -28,8 +28,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"imapflow": "2.11.0",
"imapflow": "2.11.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.349.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.102.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.155.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sanitize-html": "^2.18.1"
"sanitize-html": "^2.18.1"
@@ -49,6 +49,19 @@ main[data-mail-inbox] [hidden] { display: none !important; }
@@ -49,6 +49,19 @@ main[data-mail-inbox] [hidden] { display: none !important; }
.ib-addr-name { font-weight: 700; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-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-newmask { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding-top: 12px; border-top: 1px solid var(--ib-line); }
+.ib-settings { min-width: 0; border-top: 1px solid var(--ib-line); }
+.ib-settings > summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; color: var(--ib-muted); font-size: 12px; font-weight: 700; }
+.ib-settings > summary::after { content: '⌄'; margin-left: auto; font-size: 18px; }
+.ib-settings[open] > summary::after { transform: rotate(180deg); }
+.ib-settings[open] > summary { color: var(--ib-text); }
+.ib-person { display: grid; gap: 10px; padding-top: 14px; border-top: 1px solid var(--ib-line); }
+.ib-person .ib-h { margin: 8px 0 0; }
+.ib-person .ib-quiet { margin: 0; font-size: 12px; }
+.ib-person-domain, .ib-forward { display: grid; gap: 6px; min-width: 0; padding: 8px; border: 1px solid var(--ib-line); border-radius: 10px; overflow-wrap: anywhere; font-size: 12px; }
+.ib-dns { max-width: 100%; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ib-muted); font-size: 11px; }
+.ib-compose { display: grid; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--ib-line); }
+.ib-compose .ib-quiet { margin: 0; white-space: pre-line; font-size: 12px; }
+.ib-compose textarea { resize: vertical; }
.ib-ai-settings { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--ib-line); }
.ib-ai-settings { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--ib-line); }
.ib-ai-label { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 650; }
.ib-ai-label { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 650; }
.ib-ai-label input { accent-color: var(--ib-accent, #6657d9); }
.ib-ai-label input { accent-color: var(--ib-accent, #6657d9); }
@@ -20,8 +20,9 @@
@@ -20,8 +20,9 @@
var listEl = root.querySelector('[data-mail-list]');
var listEl = root.querySelector('[data-mail-list]');
var readEl = root.querySelector('[data-mail-read]');
var readEl = root.querySelector('[data-mail-read]');
var addresses = Array.isArray(data.addresses) ? data.addresses : [];
var addresses = Array.isArray(data.addresses) ? data.addresses : [];
- var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [];
- var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner' };
+ var current = 'all', cursor = null, rows = [], openId = null, loading = false, aiEnabled = false, triageThreads = [], aiRules = [], aiJournal = [], imapMailboxes = [], customDomains = [], forwards = [];
+ var composeDraft = { from: '', to: '', subject: '', body: '' };
+ var KIND = { mailbox: 'Your mailbox', mask: 'Mask', agent: 'Agent inbox', burner: 'Burner', name: 'Your address', alias: 'Alias', custom: 'Custom domain' };
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
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) {
function api(path, opts) {
@@ -52,6 +53,7 @@
@@ -52,6 +53,7 @@
// ── the addresses ──
// ── the addresses ──
function paintSide() {
function paintSide() {
+ var settingsOpen = !!(side.querySelector('.ib-settings') && side.querySelector('.ib-settings').open);
side.textContent = '';
side.textContent = '';
side.appendChild(el('h2', 'ib-h', 'Addresses'));
side.appendChild(el('h2', 'ib-h', 'Addresses'));
var ul = el('ul', 'ib-addrs');
var ul = el('ul', 'ib-addrs');
@@ -71,6 +73,57 @@
@@ -71,6 +73,57 @@
ul.appendChild(li);
ul.appendChild(li);
});
});
side.appendChild(ul);
side.appendChild(ul);
+ var settings = el('details', 'ib-settings'); settings.open = settingsOpen;
+ settings.appendChild(el('summary', '', 'Mail settings · ' + customDomains.length + ' domains · ' + forwards.length + ' forwards'));
+ var personal = el('section', 'ib-person');
+ personal.appendChild(el('h2', 'ib-h', 'Your address'));
+ var addressForm = el('form', 'ib-rule-form');
+ var addressName = el('input', 'ib-input'); addressName.placeholder = 'name'; addressName.required = true; addressName.setAttribute('aria-label', 'New address name');
+ var addressDomain = el('select', 'ib-input'); addressDomain.setAttribute('aria-label', 'Address domain');
+ [{ domain: 'openvibe.email' }].concat(customDomains.filter(function (d) { return d.mx_verified; })).forEach(function (d) { var option = el('option', '', '@' + d.domain); option.value = d.domain; addressDomain.appendChild(option); });
+ var addressGo = el('button', 'ib-btn is-quiet', 'Create address'); addressGo.type = 'submit';
+ var addressNote = el('p', 'ib-quiet');
+ addressForm.appendChild(addressName); addressForm.appendChild(addressDomain); addressForm.appendChild(addressGo); addressForm.appendChild(addressNote);
+ addressForm.addEventListener('submit', function (event) {
+ event.preventDefault(); addressGo.disabled = true;
+ api('/api/v1/me/mail/person-addresses', { method: 'POST', body: { name: addressName.value, domain: addressDomain.value, kind: addressDomain.value !== 'openvibe.email' ? 'custom' : addresses.some(function (a) { return a.kind === 'name'; }) ? 'alias' : 'name' } })
+ .then(function () { return api('/api/v1/me/mail/addresses'); }).then(function (result) { addresses = result.addresses || []; paintSide(); })
+ .catch(function (err) { addressGo.disabled = false; addressNote.textContent = err.message; });
+ });
+ personal.appendChild(addressForm);
+ personal.appendChild(el('h2', 'ib-h', 'Custom domains'));
+ customDomains.forEach(function (domain) {
+ var card = el('div', 'ib-person-domain'); card.appendChild(el('strong', '', domain.domain + ' · ' + domain.status.replace(/_/g, ' ')));
+ var records = el('pre', 'ib-dns', JSON.stringify(domain.dns, null, 2)); card.appendChild(records);
+ var verify = el('button', 'ib-btn is-quiet', 'Check DNS'); verify.type = 'button';
+ verify.addEventListener('click', function () { verify.disabled = true; api('/api/v1/me/mail/custom-domains/' + encodeURIComponent(domain.domain) + '/verify', { method: 'POST' }).then(loadPersonControls).then(paintSide).catch(function (err) { verify.disabled = false; card.appendChild(el('p', 'ib-problem', err.message)); }); });
+ card.appendChild(verify); personal.appendChild(card);
+ });
+ var domainForm = el('form', 'ib-rule-form');
+ var domainName = el('input', 'ib-input'); domainName.placeholder = 'example.com'; domainName.setAttribute('aria-label', 'Custom domain'); domainName.required = true;
+ var domainGo = el('button', 'ib-btn is-quiet', 'Add domain'); domainGo.type = 'submit';
+ var domainNote = el('p', 'ib-quiet');
+ domainForm.appendChild(domainName); domainForm.appendChild(domainGo); domainForm.appendChild(domainNote);
+ domainForm.addEventListener('submit', function (event) { event.preventDefault(); domainGo.disabled = true; api('/api/v1/me/mail/custom-domains', { method: 'POST', body: { domain: domainName.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { domainGo.disabled = false; domainNote.textContent = err.message; }); });
+ personal.appendChild(domainForm);
+ personal.appendChild(el('h2', 'ib-h', 'Forward to your verified email'));
+ personal.appendChild(el('p', 'ib-quiet', 'Add and verify your other address on your Network account first. A copy stays in this inbox.'));
+ forwards.forEach(function (rule) {
+ var line = el('div', 'ib-forward'); line.appendChild(el('span', '', (rule.source_address || 'All mail') + ' → ' + rule.target_address));
+ var remove = el('button', 'ib-btn is-quiet', 'Remove'); remove.type = 'button';
+ remove.addEventListener('click', function () { remove.disabled = true; api('/api/v1/me/mail/forwards/' + encodeURIComponent(rule.id), { method: 'DELETE' }).then(loadPersonControls).then(paintSide).catch(function () { remove.disabled = false; }); });
+ line.appendChild(remove); personal.appendChild(line);
+ });
+ var forwardForm = el('form', 'ib-rule-form');
+ var forwardSource = el('select', 'ib-input'); forwardSource.setAttribute('aria-label', 'Forward mail sent to');
+ [{ address: 'all' }].concat(addresses).forEach(function (a) { var option = el('option', '', a.address === 'all' ? 'All mail' : a.address); option.value = a.address; forwardSource.appendChild(option); });
+ var forwardTarget = el('input', 'ib-input'); forwardTarget.type = 'email'; forwardTarget.placeholder = '[email protected]'; forwardTarget.required = true; forwardTarget.setAttribute('aria-label', 'Verified forwarding email');
+ var forwardGo = el('button', 'ib-btn is-quiet', 'Add forwarding'); forwardGo.type = 'submit';
+ var forwardNote = el('p', 'ib-quiet');
+ forwardForm.appendChild(forwardSource); forwardForm.appendChild(forwardTarget); forwardForm.appendChild(forwardGo); forwardForm.appendChild(forwardNote);
+ forwardForm.addEventListener('submit', function (event) { event.preventDefault(); forwardGo.disabled = true; api('/api/v1/me/mail/forwards', { method: 'POST', body: { source: forwardSource.value, target: forwardTarget.value } }).then(loadPersonControls).then(paintSide).catch(function (err) { forwardGo.disabled = false; forwardNote.textContent = err.message; }); });
+ personal.appendChild(forwardForm);
+ settings.appendChild(personal);
var aiBox = el('section', 'ib-ai-settings');
var aiBox = el('section', 'ib-ai-settings');
aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI on
aiBox.id = 'inbox-ai'; // openvibe.my's Receipts card links here to turn Inbox AI on
aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));
aiBox.appendChild(el('h2', 'ib-h', 'Inbox AI'));
@@ -88,7 +141,7 @@
@@ -88,7 +141,7 @@
aiBox.appendChild(aiLabel);
aiBox.appendChild(aiLabel);
var aiCopy = el('p', 'ib-quiet', aiEnabled ? 'On · uses your OpenVibe.AI allowance. Drafts wait for your click.' : 'Off · your mail is not sent for AI triage.');
var aiCopy = el('p', 'ib-quiet', aiEnabled ? 'On · uses your OpenVibe.AI allowance. Drafts wait for your click.' : 'Off · your mail is not sent for AI triage.');
aiBox.appendChild(aiCopy);
aiBox.appendChild(aiCopy);
- side.appendChild(aiBox);
+ settings.appendChild(aiBox);
// Connected mailboxes: Inbox AI reading another provider over IMAP. Read only, app password, and Disconnect is
// Connected mailboxes: Inbox AI reading another provider over IMAP. Read only, app password, and Disconnect is
// the way back out; the summaries it pulled go only when the person asks.
// the way back out; the summaries it pulled go only when the person asks.
var imapBox = el('section', 'ib-ai-rules ib-imap');
var imapBox = el('section', 'ib-ai-rules ib-imap');
@@ -133,7 +186,7 @@
@@ -133,7 +186,7 @@
} else if (!imapMailboxes.length) {
} else if (!imapMailboxes.length) {
imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));
imapBox.appendChild(el('p', 'ib-quiet', 'Turn on Inbox AI to connect a mailbox.'));
}
}
- side.appendChild(imapBox);
+ settings.appendChild(imapBox);
var rulesBox = el('section', 'ib-ai-rules');
var rulesBox = el('section', 'ib-ai-rules');
rulesBox.appendChild(el('h2', 'ib-h', 'Rules'));
rulesBox.appendChild(el('h2', 'ib-h', 'Rules'));
function ruleForm(rule) {
function ruleForm(rule) {
@@ -165,7 +218,7 @@
@@ -165,7 +218,7 @@
}
}
aiRules.forEach(function (rule) { rulesBox.appendChild(ruleForm(rule)); });
aiRules.forEach(function (rule) { rulesBox.appendChild(ruleForm(rule)); });
rulesBox.appendChild(ruleForm({ category: 'other', enabled: true }));
rulesBox.appendChild(ruleForm({ category: 'other', enabled: true }));
- side.appendChild(rulesBox);
+ settings.appendChild(rulesBox);
var journalBox = el('section', 'ib-ai-journal');
var journalBox = el('section', 'ib-ai-journal');
journalBox.appendChild(el('h2', 'ib-h', 'Rule journal'));
journalBox.appendChild(el('h2', 'ib-h', 'Rule journal'));
aiJournal.forEach(function (entry) {
aiJournal.forEach(function (entry) {
@@ -178,7 +231,8 @@
@@ -178,7 +231,8 @@
} else line.appendChild(el('span', 'ib-quiet', 'Undone'));
} else line.appendChild(el('span', 'ib-quiet', 'Undone'));
journalBox.appendChild(line);
journalBox.appendChild(line);
});
});
- side.appendChild(journalBox);
+ settings.appendChild(journalBox);
+ side.appendChild(settings);
// New mask: one line, for one site.
// New mask: one line, for one site.
var form = el('form', 'ib-newmask');
var form = el('form', 'ib-newmask');
var input = el('input', 'ib-input');
var input = el('input', 'ib-input');
@@ -230,6 +284,24 @@
@@ -230,6 +284,24 @@
var a = addresses.find(function (x) { return x.address === current; });
var a = addresses.find(function (x) { return x.address === current; });
head.appendChild(el('h2', 'ib-h', current === 'all' ? 'All mail' : (a && a.label) || current));
head.appendChild(el('h2', 'ib-h', current === 'all' ? 'All mail' : (a && a.label) || current));
listEl.appendChild(head);
listEl.appendChild(head);
+ var compose = el('form', 'ib-compose');
+ compose.appendChild(el('h3', 'ib-h', 'Write a message'));
+ var from = el('select', 'ib-input'); from.setAttribute('aria-label', 'Send from');
+ addresses.filter(function (a) { return ['mailbox','name','alias','custom'].indexOf(a.kind) >= 0; })
+ .sort(function (a, b) { return ['name','alias','custom','mailbox'].indexOf(a.kind) - ['name','alias','custom','mailbox'].indexOf(b.kind); })
+ .forEach(function (a) { var option = el('option', '', a.address); option.value = a.address; from.appendChild(option); });
+ from.value = composeDraft.from || (from.options[0] && from.options[0].value) || '';
+ var to = el('input', 'ib-input'); to.type = 'text'; to.placeholder = 'Recipient email, or up to 10 separated by commas'; to.required = true; to.value = composeDraft.to; to.setAttribute('aria-label', 'Recipients');
+ var subject = el('input', 'ib-input'); subject.placeholder = 'Subject'; subject.required = true; subject.value = composeDraft.subject; subject.setAttribute('aria-label', 'Subject');
+ var body = el('textarea', 'ib-input'); body.placeholder = 'Your message'; body.required = true; body.rows = 5; body.value = composeDraft.body; body.setAttribute('aria-label', 'Message');
+ var send = el('button', 'ib-btn', 'Send'); send.type = 'submit';
+ var sendNote = el('p', 'ib-quiet');
+ [from,to,subject,body].forEach(function (input) { input.addEventListener('input', function () { composeDraft = { from: from.value, to: to.value, subject: subject.value, body: body.value }; }); compose.appendChild(input); });
+ compose.appendChild(send); compose.appendChild(sendNote);
+ compose.addEventListener('submit', function (event) { event.preventDefault(); send.disabled = true; api('/api/v1/me/mail/send', { method: 'POST', body: { from: from.value, to: to.value.split(',').map(function (s) { return s.trim(); }), subject: subject.value, body: body.value } })
+ .then(function (result) { sendNote.textContent = (result.results || []).map(function (r) { return r.to + ': ' + r.status + (r.reason ? ' · ' + r.reason : ''); }).join('\n'); send.disabled = false; if ((result.results || []).every(function (r) { return r.status === 'accepted'; })) { composeDraft = { from: from.value, to: '', subject: '', body: '' }; to.value = ''; subject.value = ''; body.value = ''; } })
+ .catch(function (err) { send.disabled = false; sendNote.textContent = err.message; }); });
+ listEl.appendChild(compose);
if (current === 'all' && triageThreads.length) {
if (current === 'all' && triageThreads.length) {
var groups = [
var groups = [
['needs_reply', 'Needs a reply'], ['to_read', 'To read'], ['receipts', 'Receipts'],
['needs_reply', 'Needs a reply'], ['to_read', 'To read'], ['receipts', 'Receipts'],
@@ -426,6 +498,7 @@
@@ -426,6 +498,7 @@
// The addresses come from the API when the shell does not carry them.
// The addresses come from the API when the shell does not carry them.
function begin() {
function begin() {
paintSide();
paintSide();
+ loadPersonControls().then(function () { paintSide(); paintList(); }).catch(function () {});
loadAiControls().then(function () {
loadAiControls().then(function () {
paintSide();
paintSide();
// A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.
// A link from openvibe.my's Receipts card to #inbox-ai lands on the switch, which only exists after this paint.
@@ -440,6 +513,10 @@
@@ -440,6 +513,10 @@
return Promise.all([api('/api/v1/me/mail/ai-settings'), api('/api/v1/me/mail/ai-rules'), api('/api/v1/me/mail/ai-journal'), api('/api/v1/me/mail/imap-mailboxes')])
return Promise.all([api('/api/v1/me/mail/ai-settings'), api('/api/v1/me/mail/ai-rules'), api('/api/v1/me/mail/ai-journal'), api('/api/v1/me/mail/imap-mailboxes')])
.then(function (results) { aiEnabled = !!results[0].enabled; aiRules = results[1].rules || []; aiJournal = results[2].entries || []; imapMailboxes = results[3].mailboxes || []; });
.then(function (results) { aiEnabled = !!results[0].enabled; aiRules = results[1].rules || []; aiJournal = results[2].entries || []; imapMailboxes = results[3].mailboxes || []; });
}
}
+ function loadPersonControls() {
+ return Promise.all([api('/api/v1/me/mail/custom-domains'), api('/api/v1/me/mail/forwards')])
+ .then(function (results) { customDomains = results[0].domains || []; forwards = results[1].forwards || []; });
+ }
if (addresses.length) begin();
if (addresses.length) begin();
else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });
else api('/api/v1/me/mail/addresses').then(function (b) { addresses = b.addresses || []; begin(); }).catch(function () { begin(); });
var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);
var start = /^\/inbox\/([^/]+)$/.exec(location.pathname);
@@ -123,6 +123,7 @@ function load(overrides = null) {
@@ -123,6 +123,7 @@ function load(overrides = null) {
mask: maskDomain,
mask: maskDomain,
burners: burnerDomains,
burners: burnerDomains,
},
},
+ inbound: { mx: list('MAIL_CUSTOM_DOMAIN_MX') },
stalwart: {
stalwart: {
url: (env.STALWART_URL || 'http://127.0.0.1:8080').replace(/\/+$/, ''),
url: (env.STALWART_URL || 'http://127.0.0.1:8080').replace(/\/+$/, ''),
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+
+const crypto = require('node:crypto');
+const dns = require('node:dns/promises');
+const { sql } = require('openvibe-sdk/db');
+const { validDomain } = require('./provider-domains');
+const providers = require('./outbound-providers');
+const adapters = require('./provider-adapters');
+const { ensureOwnerMailbox } = require('./mailboxes');
+
+const txtValues = async (host, lookupTxt) => (await lookupTxt(host).catch(() => [])).map((parts) => Array.isArray(parts) ? parts.join('') : String(parts));
+const statusOf = (row) => !row.ownership_verified_at ? 'pending_ownership' : !row.mx_verified_at ? 'pending_mx' : !row.dkim_verified_at ? 'pending_dkim' : 'active';
+
+async function add({ db, config, owner, domain: raw }) {
+ const domain = validDomain(raw);
+ if (domain === config.addresses.base || domain.endsWith(`.${config.addresses.base}`) || config.addresses.burners.includes(domain)) throw new Error('This domain belongs to OpenVibe.');
+ const token = crypto.randomBytes(24).toString('hex');
+ await db.tx((t) => ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base }));
+ const verified = await db.maybe(sql`SELECT owner_subject FROM mail_custom_domains WHERE domain=${domain} AND ownership_verified_at IS NOT NULL`);
+ if (verified && verified.owner_subject !== owner) throw new Error('This domain is already in use on OpenVibe.');
+ await db.exec(sql`INSERT INTO mail_custom_domains(domain,owner_subject,ownership_token) VALUES(${domain},${owner},${token})
+ ON CONFLICT (domain,owner_subject) DO NOTHING`);
+ return get({ db, config, owner, domain });
+}
+
+async function get({ db, config, owner, domain }) {
+ const row = await db.maybe(sql`SELECT domain,ownership_token,ownership_verified_at,mx_verified_at,dkim_verified_at FROM mail_custom_domains WHERE domain=${domain} AND owner_subject=${owner}`);
+ if (!row) return null;
+ const configured = await db.many(sql`SELECT d.provider_id,d.dkim_records,d.spf_include,p.name FROM mail_outbound_domains d JOIN mail_outbound_providers p ON p.id=d.provider_id AND p.enabled=true WHERE d.domain=${domain}`);
+ return { domain, status: statusOf(row), ownership_verified: !!row.ownership_verified_at, mx_verified: !!row.mx_verified_at,
+ dkim_verified: !!row.dkim_verified_at, dns: { ownership: { type: 'TXT', name: `_openvibe-mail.${domain}`, value: row.ownership_token },
+ mx: (config.inbound?.mx || []).map((host, i) => ({ type: 'MX', name: domain, priority: (i+1)*10, value: host })),
+ dkim: configured.flatMap((item) => item.dkim_records || []),
+ spf: `v=spf1 ${[...new Set(configured.map((item) => item.spf_include).filter(Boolean))].map((host) => `include:${host}`).join(' ')} -all`.replace(/\s+/g,' ').trim() },
+ providers: configured.map((item) => ({ id: item.provider_id, name: item.name })) };
+}
+
+async function verify({ db, config, stalwart, owner, domain: raw, lookupTxt = dns.resolveTxt, lookupMx = dns.resolveMx, fetchImpl = globalThis.fetch }) {
+ const domain = validDomain(raw);
+ const row = await db.maybe(sql`SELECT * FROM mail_custom_domains WHERE domain=${domain} AND owner_subject=${owner}`);
+ if (!row) return null;
+ const verified = await db.maybe(sql`SELECT owner_subject FROM mail_custom_domains WHERE domain=${domain} AND ownership_verified_at IS NOT NULL`);
+ if (verified && verified.owner_subject !== owner) throw new Error('This domain is already in use on OpenVibe.');
+ const ownership = (await txtValues(`_openvibe-mail.${domain}`, lookupTxt)).includes(row.ownership_token);
+ const expected = new Set(config.inbound?.mx || []);
+ const actualMx = await lookupMx(domain).catch(() => []);
+ const mx = expected.size > 0 && actualMx.length > 0 && actualMx.every((record) => expected.has(String(record.exchange || '').toLowerCase().replace(/\.$/,'')));
+ let dkim = false;
+ if (ownership && mx) {
+ const configured = await db.many(sql`SELECT d.provider_id,d.dkim_records,p.* FROM mail_outbound_domains d JOIN mail_outbound_providers p ON p.id=d.provider_id WHERE d.domain=${domain} AND p.enabled=true`);
+ for (const provider of configured) {
+ const records = (provider.dkim_records || []).filter((r) => r.type === 'TXT' && /_domainkey/i.test(r.name));
+ if (!records.length) continue;
+ const matches = await Promise.all(records.map(async (record) => (await txtValues(record.name, lookupTxt)).includes(record.value)));
+ if (!matches.every(Boolean)) continue;
+ let credential = providers.open(provider, providers.keyFrom(config));
+ try { credential = JSON.parse(credential); } catch { /* opaque provider token */ }
+ const settings = { ...(provider.settings || {}), dkim_hosts: records.map((record) => record.name) };
+ try { if (await adapters.domainStatus({ route: { adapter: provider.adapter, settings, password: credential }, domain, fetchImpl, lookupTxt, lookupMx })) { dkim = true; break; } }
+ catch { /* provider status unavailable: do not enable sending */ }
+ }
+ }
+ try {
+ await db.exec(sql`UPDATE mail_custom_domains SET ownership_verified_at=CASE WHEN ${ownership} THEN COALESCE(ownership_verified_at,now()) ELSE NULL END,
+ mx_verified_at=CASE WHEN ${ownership && mx} THEN COALESCE(mx_verified_at,now()) ELSE NULL END,
+ dkim_verified_at=CASE WHEN ${ownership && mx && dkim} THEN COALESCE(dkim_verified_at,now()) ELSE NULL END WHERE domain=${domain} AND owner_subject=${owner}`);
+ } catch (err) {
+ if (err.code === '23505' && err.constraint === 'mail_custom_domains_verified_domain') throw new Error('This domain is already in use on OpenVibe.');
+ throw err;
+ }
+ if (ownership && mx && stalwart?.ensureDomain) await stalwart.ensureDomain(domain);
+ return get({ db, config, owner, domain });
+}
+
+module.exports = { add, get, verify, statusOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+const crypto = require('node:crypto');
+const { sql } = require('openvibe-sdk/db');
+const { cleanAddress, senderAllowed, buildMessage } = require('./person-mail');
+const { mailboxAddressFor } = require('./mailboxes');
+const providers = require('./outbound-providers');
+
+async function add({ db, owner, source, target, policy }) {
+ const to = cleanAddress(target);
+ if (!to) throw new Error('Enter a valid forwarding address.');
+ const address = source === 'all' || !source ? null : cleanAddress(source);
+ if (source && source !== 'all' && !address) throw new Error('Choose one of your addresses or all mail.');
+ if (address && !await senderAllowed(db, owner, address)
+ && !await db.maybe(sql`SELECT 1 AS ok FROM mail_masks WHERE owner_subject=${owner} AND address=${address} AND enabled=true`)
+ && !await db.maybe(sql`SELECT 1 AS ok FROM mail_agent_inboxes WHERE owner_subject=${owner} AND address=${address}`)) throw new Error('Choose one of your active addresses.');
+ if (!await policy.verifiedEmail(owner, to)) throw new Error('Verify this address on your Network account before forwarding to it.');
+ const mailbox = await mailboxAddressFor(db, owner);
+ if (!mailbox) throw new Error('Create your mailbox before adding a forwarding rule.');
+ if (to === mailbox || to === address) throw new Error('A forwarding rule cannot send mail back to itself.');
+ const existing = await db.maybe(sql`SELECT id FROM mail_forward_rules WHERE owner_subject=${owner} AND source_address IS NOT DISTINCT FROM ${address} AND target_address=${to}`);
+ if (existing) return existing.id;
+ const id = crypto.randomUUID();
+ await db.exec(sql`INSERT INTO mail_forward_rules(id,owner_subject,source_address,target_address) VALUES(${id},${owner},${address},${to})`);
+ return id;
+}
+
+async function run({ db, stalwart, config, policy, smtp, fetchImpl = globalThis.fetch, limit = 100 }) {
+ if (!config.outbound?.enabled) return { sent: 0 };
+ const rules = await db.many(sql`SELECT r.id,r.owner_subject,r.source_address,r.target_address,m.address AS mailbox FROM mail_forward_rules r
+ JOIN mail_mailboxes m ON m.owner_subject=r.owner_subject WHERE r.enabled=true ORDER BY r.created_at LIMIT ${limit}`);
+ const groups = new Map();
+ for (const rule of rules) {
+ if (!groups.has(rule.mailbox)) groups.set(rule.mailbox, []);
+ groups.get(rule.mailbox).push(rule);
+ }
+ let sent = 0;
+ for (const [mailbox, group] of groups) {
+ let page;
+ try { page = await stalwart.readMessages({ mailboxAddress: mailbox, limit: 100 }); } catch { continue; }
+ for (const row of page.messages || []) {
+ if (row.spam) continue;
+ const matching = group.filter((rule) => !rule.source_address || rule.source_address === row.to?.[0]);
+ if (!matching.length) continue;
+ let message;
+ try { message = await stalwart.readMessage({ mailboxAddress: mailbox, id: row.id }); } catch { continue; }
+ if (!message || message.spam || !message.deliveredTo || (message.attachments || []).length) continue;
+ for (const rule of matching) {
+ if (rule.source_address && rule.source_address !== message.deliveredTo) continue;
+ if (rule.target_address === message.deliveredTo || (message.from || []).some((from) => String(from).toLowerCase().includes(rule.target_address))) continue;
+ let verified = false;
+ try { verified = await policy.verifiedEmail(rule.owner_subject, rule.target_address); } catch { continue; }
+ if (!verified) { await db.exec(sql`UPDATE mail_forward_rules SET enabled=false WHERE id=${rule.id}`); continue; }
+ const claimed = await db.maybe(sql`INSERT INTO mail_forward_deliveries(rule_id,message_id,state) VALUES(${rule.id},${message.id},'sending')
+ ON CONFLICT(rule_id,message_id) DO UPDATE SET state='sending',updated_at=now()
+ WHERE mail_forward_deliveries.state='failed' AND mail_forward_deliveries.updated_at < now()-interval '5 minutes' RETURNING rule_id`);
+ if (!claimed) continue;
+ const day = new Date().toISOString().slice(0,10);
+ const allowance = await db.tx(async (t) => {
+ await t.exec(sql`INSERT INTO mail_forward_daily(owner_subject,day) VALUES(${rule.owner_subject},${day}) ON CONFLICT DO NOTHING`);
+ const used = await t.maybe(sql`SELECT attempts FROM mail_forward_daily WHERE owner_subject=${rule.owner_subject} AND day=${day} FOR UPDATE`);
+ if (Number(used.attempts) >= 100) return false;
+ await t.exec(sql`UPDATE mail_forward_daily SET attempts=attempts+1 WHERE owner_subject=${rule.owner_subject} AND day=${day}`);
+ return true;
+ });
+ if (!allowance) { await db.exec(sql`UPDATE mail_forward_deliveries SET state='failed',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`); continue; }
+ const body = `Forwarded from ${String(message.from?.[0] || 'unknown sender').slice(0,180)}\n\n${String(message.text || '').slice(0,9000)}`;
+ const id = crypto.randomUUID();
+ let raw;
+ try { raw = buildMessage({ from: mailbox, to: rule.target_address, subject: `Fwd: ${message.subject || '(no subject)'}`.slice(0,200), body, id, reportBase: config.baseUrl, now: new Date() }); }
+ catch { await db.exec(sql`UPDATE mail_forward_deliveries SET state='failed',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`); continue; }
+ raw = raw.replace('\r\nMIME-Version:', '\r\nAuto-Submitted: auto-forwarded\r\nMIME-Version:');
+ let result;
+ try { result = await providers.sendWithFailover({ db, config, from: mailbox, to: rule.target_address, message: raw, kind: 'person', smtp, fetchImpl }); }
+ catch { continue; }
+ if (result.ok) { await db.exec(sql`UPDATE mail_forward_deliveries SET state='sent',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`); sent++; }
+ else if (!result.uncertain) await db.exec(sql`UPDATE mail_forward_deliveries SET state='failed',updated_at=now() WHERE rule_id=${rule.id} AND message_id=${message.id}`);
+ }
+ }
+ }
+ return { sent };
+}
+
+module.exports = { add, run };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createTrustClient } = require('openvibe-sdk/trust');
+
+function createNetworkPolicy(config, fetchImpl = globalThis.fetch) {
+ const tokens = config.oauth.clientSecret && createServiceTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: 'openvibe.network',
+ scope: 'identity.trust.read identity.emails.read', fetch: fetchImpl,
+ });
+ const getToken = async () => {
+ if (!tokens) throw new Error('Mail cannot verify ownership until its Network service credentials are configured.');
+ return tokens.getToken();
+ };
+ const trust = createTrustClient({ networkUrl: config.network.internalUrl, getToken, fetchImpl });
+ return {
+ trust: (subject) => trust.of(subject),
+ async verifiedEmail(subject, email) {
+ const response = await fetchImpl(`${config.network.internalUrl}/api/v1/identity/emails/verified-check`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ subject, email }), signal: AbortSignal.timeout(5000),
+ });
+ if (!response.ok) throw new Error('Network could not verify this forwarding address.');
+ const body = await response.json();
+ return body.verified === true;
+ },
+ };
+}
+
+module.exports = { createNetworkPolicy };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+
+const crypto = require('node:crypto');
+const { sql } = require('openvibe-sdk/db');
+const reply = require('./agent-reply');
+const sending = require('./sending');
+const providers = require('./outbound-providers');
+const { ensureOwnerMailbox, mailboxAddressFor } = require('./mailboxes');
+
+const ADDRESS = /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$/;
+const NAME = /^[a-z][a-z0-9-]{2,31}$/;
+const BLOCKED = /^(?:admin|abuse|billing|contact|help|info|mail|mailer-daemon|moderator|noreply|postmaster|root|security|support|webmaster|fuck|shit|nazi|hitler)(?:-|$)/i;
+
+function cleanAddress(value) {
+ const address = String(value || '').trim().toLowerCase();
+ return address.length <= 254 && ADDRESS.test(address) ? address : null;
+}
+function validateName(value) {
+ const name = String(value || '').trim().toLowerCase();
+ if (!NAME.test(name) || BLOCKED.test(name) || name.endsWith('-') || name.includes('--')) throw new Error('Choose a name of 3–32 letters, numbers or hyphens that is not reserved.');
+ return name;
+}
+function dailyLimit(signals, history) {
+ if (!signals || signals.kind !== 'account' || !signals.email_verified || Number(signals.account_age_hours) < 24) return 0;
+ if (Number(signals.account_age_days) >= 90 && signals.verified && history >= 100) return 100;
+ if (Number(signals.account_age_days) >= 30 && signals.verified && history >= 20) return 50;
+ return 20;
+}
+function paused(history) {
+ const accepted = history.filter((row) => ['accepted','bounced','complaint'].includes(row.status));
+ if (accepted.length < 10) return null;
+ const bounced = accepted.filter((row) => row.status === 'bounced').length;
+ const complaints = accepted.filter((row) => row.status === 'complaint').length;
+ if (complaints / accepted.length >= 0.01) return 'Sending is paused after a complaint. Contact Mail support to review it.';
+ if (bounced / accepted.length >= 0.1) return 'Sending is paused after too many bounced messages. Check your recipients and contact Mail support.';
+ return null;
+}
+async function recentHistory(db, owner) {
+ return db.many(sql`SELECT status FROM mail_person_sends WHERE owner_subject=${owner} AND created_at > now()-interval '30 days' ORDER BY created_at DESC LIMIT 100`);
+}
+async function createAddress({ db, config, stalwart, owner, local, domain, kind = 'alias' }) {
+ const name = validateName(local);
+ const host = String(domain || config.addresses.base).toLowerCase();
+ if (!['name','alias','custom'].includes(kind) || (kind === 'custom') !== (host !== config.addresses.base)) throw new Error('Choose an address type that matches its domain.');
+ if (host !== config.addresses.base) {
+ const ready = await db.maybe(sql`SELECT 1 AS ok FROM mail_custom_domains WHERE domain=${host} AND owner_subject=${owner} AND ownership_verified_at IS NOT NULL AND mx_verified_at IS NOT NULL`);
+ if (!ready) throw new Error('Verify domain ownership and MX before creating its address.');
+ }
+ const address = `${name}@${host}`;
+ await db.tx(async (t) => {
+ await ensureOwnerMailbox(t, { ownerSubject: owner, baseDomain: config.addresses.base });
+ if (kind === 'name' && await t.maybe(sql`SELECT 1 AS found FROM mail_person_addresses WHERE owner_subject=${owner} AND kind='name'`)) throw new Error('You already have a named address; use an alias.');
+ const count = await t.maybe(sql`SELECT count(*) AS n FROM mail_person_addresses WHERE owner_subject=${owner}`);
+ if (Number(count.n) >= 20) throw new Error('You can have up to 20 personal addresses.');
+ const existing = await t.maybe(sql`SELECT owner_subject FROM mail_person_addresses WHERE address=${address}`);
+ if (existing) { if (existing.owner_subject !== owner) throw new Error('That address is taken.'); return; }
+ for (const table of ['mail_mailboxes','mail_masks','mail_burners','mail_agent_inboxes']) {
+ const other = await t.maybe(sql`SELECT 1 AS taken FROM ${sql.raw(table)} WHERE address=${address}`);
+ if (other) throw new Error('That address is taken.');
+ }
+ await t.exec(sql`INSERT INTO mail_person_addresses(address,owner_subject,kind) VALUES(${address},${owner},${kind})`);
+ });
+ if (stalwart && stalwart.invalidateCaches) await stalwart.invalidateCaches();
+ return address;
+}
+
+async function senderAllowed(db, owner, address) {
+ const mailbox = await mailboxAddressFor(db, owner);
+ if (address === mailbox) return true;
+ return Boolean(await db.maybe(sql`SELECT 1 AS ok FROM mail_person_addresses p LEFT JOIN mail_custom_domains d ON d.domain=split_part(p.address,'@',2)
+ AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL
+ WHERE p.owner_subject=${owner} AND p.address=${address} AND p.enabled=true AND (p.kind<>'custom' OR d.dkim_verified_at IS NOT NULL)`));
+}
+
+function buildMessage({ from, to, subject, body, id, reportBase, now }) {
+ const title = reply.headerValue(subject);
+ if (title === null || !title || title.length > 200) throw new Error('A subject of at most 200 characters is required.');
+ const text = String(body || '');
+ if (!text.trim() || text.length > reply.MAX_BODY) throw new Error('A message body of at most 10,000 characters is required.');
+ const headers = [`From: ${from}`, `To: ${to}`, `Subject: ${title}`, `Date: ${reply.rfcDate(now)}`,
+ `Message-ID: <${reply.newMessageId(from.split('@')[1])}>`, `X-OpenVibe-Report-Abuse: ${reportBase}/report/${id}`,
+ 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: 8bit'];
+ return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;
+}
+
+async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch }) {
+ const sender = cleanAddress(from);
+ const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];
+ if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };
+ try { buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }
+ catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
+ if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };
+ let signals;
+ try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }
+ const history = await recentHistory(db, owner);
+ const limit = dailyLimit(signals, history.filter((r) => r.status === 'accepted').length);
+ if (!limit) return { status: 403, code: 'mail.send.trust', detail: 'Verify your account email and wait until your account is one day old before sending.' };
+ const pause = paused(history);
+ if (pause) return { status: 403, code: 'mail.send.paused', detail: pause };
+ const day = now.toISOString().slice(0,10);
+ const reserved = await db.tx(async (t) => {
+ await t.maybe(sql`SELECT owner_subject FROM mail_mailboxes WHERE owner_subject=${owner} FOR UPDATE`);
+ await t.exec(sql`INSERT INTO mail_person_daily(owner_subject,day) VALUES(${owner},${day}) ON CONFLICT DO NOTHING`);
+ const row = await t.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day} FOR UPDATE`);
+ if (Number(row.attempts) + targets.length > limit) return false;
+ await t.exec(sql`UPDATE mail_person_daily SET attempts=attempts+${targets.length} WHERE owner_subject=${owner} AND day=${day}`);
+ return true;
+ });
+ if (!reserved) return { status: 429, code: 'mail.send.daily_limit', detail: `Your daily allowance is ${limit} recipients. It grows with account history.` };
+ const mailer = smtp || require('../stalwart/smtp');
+ const results = [];
+ for (const to of targets) {
+ const id = crypto.randomUUID();
+ await db.exec(sql`INSERT INTO mail_person_sends(id,owner_subject,sender,recipient,status) VALUES(${id},${owner},${sender},${to},'sending')`);
+ let message;
+ try { message = buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }
+ catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
+ let result;
+ const route = sending.routeFor(to, config);
+ try {
+ if (sending.isLocal(to, config)) result = await mailer.sendMessage(route, { from: sender, to, message });
+ else if (!config.outbound.enabled) result = { ok: false, reason: sending.OUTBOUND_CLOSED };
+ else result = await providers.sendWithFailover({ db, config, from: sender, to, message, kind: 'person', smtp: mailer, fetchImpl, now });
+ } catch { result = { ok: false, uncertain: true, reason: 'The provider response was uncertain.' }; }
+ const state = result.ok ? 'accepted' : result.uncertain ? 'uncertain' : 'failed';
+ await db.exec(sql`UPDATE mail_person_sends SET status=${state},provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);
+ if (result.ok) await db.exec(sql`UPDATE mail_person_daily SET accepted=accepted+1 WHERE owner_subject=${owner} AND day=${day}`);
+ results.push({ id, to, status: state, reason: result.ok ? null : String(result.reason || 'The provider refused this message.').slice(0,180) });
+ }
+ return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };
+}
+
+module.exports = { cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };
@@ -110,7 +110,8 @@ const apiAdapters = {
@@ -110,7 +110,8 @@ const apiAdapters = {
// AWS SES v2 SendEmail API and SigV4: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html
// AWS SES v2 SendEmail API and SigV4: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html
ses: {
ses: {
async send(ctx) {
async send(ctx) {
- const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Simple: { Subject: { Data: ctx.message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: ctx.message.text, Charset: 'UTF-8' } } } } };
+ const c = ctx.credentials, body = { FromEmailAddress: ctx.message.from, Destination: { ToAddresses: [ctx.message.to] }, Content: { Simple: { Subject: { Data: ctx.message.subject, Charset: 'UTF-8' }, Body: { Text: { Data: ctx.message.text, Charset: 'UTF-8' } },
+ ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) } } };
const raw = JSON.stringify(body); const signed = sesAuthorization(c, raw);
const raw = JSON.stringify(body); const signed = sesAuthorization(c, raw);
const result = await post(ctx.fetch, `https://${signed.host}/v2/email/outbound-emails`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }, body);
const result = await post(ctx.fetch, `https://${signed.host}/v2/email/outbound-emails`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }, body);
return { messageId: result.MessageId || null };
return { messageId: result.MessageId || null };
@@ -124,7 +125,7 @@ const apiAdapters = {
@@ -124,7 +125,7 @@ const apiAdapters = {
},
},
// Brevo transactional send: https://developers.brevo.com/reference/send-transac-email
// Brevo transactional send: https://developers.brevo.com/reference/send-transac-email
brevo: {
brevo: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text }); return { messageId: data.messageId || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.brevo.com/v3/smtp/email', { 'api-key': ctx.credentials }, { sender: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, textContent: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.messageId || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.brevo.com/v3/senders?limit=100', { 'api-key': ctx.credentials }); return (data.senders || []).some((s) => s.domain === domain && s.active === true); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.brevo.com/v3/senders?limit=100', { 'api-key': ctx.credentials }); return (data.senders || []).some((s) => s.domain === domain && s.active === true); },
},
},
// Mailjet Send API v3.1: https://dev.mailjet.com/email/guides/send-api-v31/
// Mailjet Send API v3.1: https://dev.mailjet.com/email/guides/send-api-v31/
@@ -139,7 +140,7 @@ const apiAdapters = {
@@ -139,7 +140,7 @@ const apiAdapters = {
},
},
// Resend send email: https://resend.com/docs/api-reference/emails/send-email
// Resend send email: https://resend.com/docs/api-reference/emails/send-email
resend: {
resend: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text }); return { messageId: data.id || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.resend.com/emails', { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text, ...(ctx.message.headers['x-openvibe-report-abuse'] ? { headers: { 'X-OpenVibe-Report-Abuse': ctx.message.headers['x-openvibe-report-abuse'] } } : {}) }); return { messageId: data.id || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.resend.com/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.status === 'verified'); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.resend.com/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.status === 'verified'); },
},
},
// SMTP2GO Send Email: https://developers.smtp2go.com/docs/send-an-email . Verified sender APIs: https://developers.smtp2go.com/docs/getting-started
// SMTP2GO Send Email: https://developers.smtp2go.com/docs/send-an-email . Verified sender APIs: https://developers.smtp2go.com/docs/getting-started
@@ -154,7 +155,7 @@ const apiAdapters = {
@@ -154,7 +155,7 @@ const apiAdapters = {
},
},
// Postmark Email API: https://postmarkapp.com/developer/api/email-api
// Postmark Email API: https://postmarkapp.com/developer/api/email-api
postmark: {
postmark: {
- async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, MessageStream: ctx.settings.message_stream || 'outbound' }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },
+ async send(ctx) { const data = await post(ctx.fetch, 'https://api.postmarkapp.com/email', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }, { From: ctx.message.from, To: ctx.message.to, Subject: ctx.message.subject, TextBody: ctx.message.text, MessageStream: ctx.settings.message_stream || 'outbound', ...(ctx.message.headers['x-openvibe-report-abuse'] ? { Headers: [{ Name: 'X-OpenVibe-Report-Abuse', Value: ctx.message.headers['x-openvibe-report-abuse'] }] } : {}) }); if (Number(data.ErrorCode || 0) !== 0) throw new ProviderError(String(data.Message || 'Postmark rejected the message.'), { status: 400 }); return { messageId: data.MessageID || null }; },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.postmarkapp.com/senders', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }); return (data.SenderSignatures || []).some((s) => String(s.FromEmail || '').toLowerCase().endsWith(`@${domain}`) && s.Confirmed === true && s.DKIMVerified === true); },
async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.postmarkapp.com/senders', { 'X-Postmark-Server-Token': ctx.credentials, accept: 'application/json' }); return (data.SenderSignatures || []).some((s) => String(s.FromEmail || '').toLowerCase().endsWith(`@${domain}`) && s.Confirmed === true && s.DKIMVerified === true); },
},
},
};
};
@@ -174,7 +175,8 @@ function normalizeWebhook(adapter, payload) {
@@ -174,7 +175,8 @@ function normalizeWebhook(adapter, payload) {
const recipient = String(sesRecipient || root.email || root.Email || root.recipient || root.recipient_email || root.Recipient || root.To || root.to || nested.email || nested.Email || nested.recipient || nested.recipient_email || nested.Recipient || nested.To || nested.to || nested.address || firstRecipient?.email || firstRecipient?.Email || firstRecipient || '').trim().toLowerCase();
const recipient = String(sesRecipient || root.email || root.Email || root.recipient || root.recipient_email || root.Recipient || root.To || root.to || nested.email || nested.Email || nested.recipient || nested.recipient_email || nested.Recipient || nested.To || nested.to || nested.address || firstRecipient?.email || firstRecipient?.Email || firstRecipient || '').trim().toLowerCase();
const soft = /soft.?bounc|deferred|temporary|transient/.test(event);
const soft = /soft.?bounc|deferred|temporary|transient/.test(event);
const hard = /hard.?bounc|permanent.?bounc|undeliverable|spam.?complaint|complaint|blocked/.test(event) || (!soft && /bounc(?:e|ed)/.test(event));
const hard = /hard.?bounc|permanent.?bounc|undeliverable|spam.?complaint|complaint|blocked/.test(event) || (!soft && /bounc(?:e|ed)/.test(event));
- return { adapter, event, recipient, kind: /(complaint|spam)/.test(event) ? 'complaint' : soft ? 'soft_bounce' : hard ? 'hard_bounce' : /delivered|delivery/.test(event) ? 'delivered' : 'unknown' };
+ const messageId = String(root.mail?.messageId || root.MessageID || root.MessageId || root.message_id || nested.email_id || nested.message_id || nested.messageId || '').trim() || null;
+ return { adapter, event, recipient, messageId, kind: /(complaint|spam)/.test(event) ? 'complaint' : soft ? 'soft_bounce' : hard ? 'hard_bounce' : /delivered|delivery/.test(event) ? 'delivered' : 'unknown' };
}
}
function adapterFor(name) {
function adapterFor(name) {
@@ -112,6 +112,10 @@ async function ingest({ db, provider, payload, now = new Date() }) {
@@ -112,6 +112,10 @@ async function ingest({ db, provider, payload, now = new Date() }) {
const inserted = await t.maybe(sql`INSERT INTO mail_outbound_webhook_events(provider_id,event_id,recipient,recipient_domain,kind)
const inserted = await t.maybe(sql`INSERT INTO mail_outbound_webhook_events(provider_id,event_id,recipient,recipient_domain,kind)
VALUES (${provider.id},${id},${address},${recipientDomain},${normalized.kind}) ON CONFLICT DO NOTHING RETURNING event_id`);
VALUES (${provider.id},${id},${address},${recipientDomain},${normalized.kind}) ON CONFLICT DO NOTHING RETURNING event_id`);
if (!inserted) return { accepted: true, duplicate: true };
if (!inserted) return { accepted: true, duplicate: true };
+ if (normalized.messageId && (normalized.kind === 'hard_bounce' || normalized.kind === 'complaint')) {
+ await t.exec(sql`UPDATE mail_person_sends SET status=${normalized.kind === 'complaint' ? 'complaint' : 'bounced'}
+ WHERE provider_id=${provider.id} AND provider_message_id=${normalized.messageId} AND recipient=${address} AND status='accepted'`);
+ }
if (normalized.kind === 'hard_bounce' || normalized.kind === 'complaint') {
if (normalized.kind === 'hard_bounce' || normalized.kind === 'complaint') {
await t.exec(sql`INSERT INTO mail_outbound_suppressions(address,reason,source_provider) VALUES (${address},${normalized.kind},${provider.id})
await t.exec(sql`INSERT INTO mail_outbound_suppressions(address,reason,source_provider) VALUES (${address},${normalized.kind},${provider.id})
ON CONFLICT(address) DO UPDATE SET reason=CASE WHEN mail_outbound_suppressions.reason='complaint' THEN 'complaint' ELSE excluded.reason END`);
ON CONFLICT(address) DO UPDATE SET reason=CASE WHEN mail_outbound_suppressions.reason='complaint' THEN 'complaint' ELSE excluded.reason END`);
@@ -328,6 +328,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
@@ -328,6 +328,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
// Reading your own mail (M1.5): server/http/reading-routes.js.
// Reading your own mail (M1.5): server/http/reading-routes.js.
require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });
require('./reading-routes').mountReading(router, { db, stalwart, config, smtp, principal, wrap, fail, ownerOf });
+ require('./person-routes').mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl });
// Inbox AI pulling another provider's mailbox over IMAP: server/http/imap-routes.js.
// Inbox AI pulling another provider's mailbox over IMAP: server/http/imap-routes.js.
require('./imap-routes').mountImap(router, { db, config, principal, wrap, fail, ownerOf, ...(imap ? { imap } : {}) });
require('./imap-routes').mountImap(router, { db, config, principal, wrap, fail, ownerOf, ...(imap ? { imap } : {}) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+const { sql } = require('openvibe-sdk/db');
+const addresses = require('../domain/person-mail');
+const customDomains = require('../domain/custom-domains');
+const forwarding = require('../domain/forwarding');
+const { createNetworkPolicy } = require('../domain/network-policy');
+
+function mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap, fail, ownerOf, fetchImpl }) {
+ const read = principal.requirePerson(principal.CAPABILITIES.read);
+ const write = principal.requirePerson(principal.CAPABILITIES.write);
+ const policy = createNetworkPolicy(config, fetchImpl);
+ const noStore = (res) => res.set('Cache-Control', 'private, no-store');
+ const humanOnly = (req, res, next) => req.principal?.kind === 'user' ? next()
+ : fail(req, res, 403, 'mail.person_required', 'Sign in as the account owner to send or forward mail.');
+
+ router.get('/api/v1/me/mail/person-addresses', read, wrap(async (req, res) => {
+ const rows = await db.many(sql`SELECT address,kind,enabled,created_at FROM mail_person_addresses WHERE owner_subject=${ownerOf(req)} ORDER BY created_at`);
+ return noStore(res).json({ addresses: rows });
+ }));
+ router.post('/api/v1/me/mail/person-addresses', write, wrap(async (req, res) => {
+ const kind = req.body?.kind === 'name' ? 'name' : req.body?.kind === 'custom' ? 'custom' : 'alias';
+ try {
+ const address = await addresses.createAddress({ db, config, stalwart, owner: ownerOf(req), local: req.body?.name, domain: req.body?.domain, kind });
+ return noStore(res).status(201).json({ address, kind });
+ } catch (err) { return fail(req, res, 422, 'mail.address.invalid', err.message); }
+ }));
+
+ router.get('/api/v1/me/mail/custom-domains', read, wrap(async (req, res) => {
+ const rows = await db.many(sql`SELECT domain FROM mail_custom_domains WHERE owner_subject=${ownerOf(req)} ORDER BY created_at`);
+ return noStore(res).json({ domains: await Promise.all(rows.map((row) => customDomains.get({ db, config, owner: ownerOf(req), domain: row.domain }))) });
+ }));
+ router.post('/api/v1/me/mail/custom-domains', write, wrap(async (req, res) => {
+ try { return noStore(res).status(201).json(await customDomains.add({ db, config, owner: ownerOf(req), domain: req.body?.domain })); }
+ catch (err) { return fail(req, res, 422, 'mail.custom_domain.invalid', err.message); }
+ }));
+ router.post('/api/v1/me/mail/custom-domains/:domain/verify', write, wrap(async (req, res) => {
+ try {
+ const result = await customDomains.verify({ db, config, stalwart, owner: ownerOf(req), domain: req.params.domain, fetchImpl });
+ if (!result) return fail(req, res, 404, 'mail.custom_domain.not_found', 'That domain is not yours.');
+ return noStore(res).json(result);
+ } catch (err) { return fail(req, res, 422, 'mail.custom_domain.invalid', err.message); }
+ }));
+
+ router.post('/api/v1/me/mail/send', write, humanOnly, wrap(async (req, res) => {
+ const result = await addresses.send({ db, config, owner: ownerOf(req), from: req.body?.from,
+ recipients: req.body?.to, subject: req.body?.subject, body: req.body?.body, policy, smtp, fetchImpl });
+ if (result.code) return fail(req, res, result.status, result.code, result.detail);
+ return noStore(res).status(result.status).json(result);
+ }));
+
+ router.get('/api/v1/me/mail/forwards', read, wrap(async (req, res) => {
+ const rows = await db.many(sql`SELECT id,source_address,target_address,enabled,created_at FROM mail_forward_rules WHERE owner_subject=${ownerOf(req)} ORDER BY created_at`);
+ return noStore(res).json({ forwards: rows });
+ }));
+ router.post('/api/v1/me/mail/forwards', write, humanOnly, wrap(async (req, res) => {
+ try {
+ const id = await forwarding.add({ db, owner: ownerOf(req), source: req.body?.source, target: req.body?.target, policy });
+ return noStore(res).status(201).json({ id });
+ } catch (err) { return fail(req, res, 422, 'mail.forward.invalid', err.message); }
+ }));
+ router.delete('/api/v1/me/mail/forwards/:id', write, humanOnly, wrap(async (req, res) => {
+ const row = await db.maybe(sql`DELETE FROM mail_forward_rules WHERE id=${req.params.id} AND owner_subject=${ownerOf(req)} RETURNING id`);
+ if (!row) return fail(req, res, 404, 'mail.forward.not_found', 'That forwarding rule is not yours.');
+ return noStore(res).status(204).end();
+ }));
+
+ router.get('/report/:id', (req, res) => {
+ if (!/^[0-9a-f-]{36}$/.test(req.params.id)) return res.status(404).end();
+ res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-store');
+ return res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>Report a message</title><main><h1>Report an OpenVibe message</h1><p>Use this if this message was unwanted. Your report pauses sending when abuse is detected.</p><form method="post" action="/api/v1/mail/report/${req.params.id}"><button type="submit">Report message</button></form></main></html>`);
+ });
+ router.post('/api/v1/mail/report/:id', wrap(async (req, res) => {
+ if (!/^[0-9a-f-]{36}$/.test(req.params.id)) return fail(req, res, 404, 'mail.report.not_found', 'The message was not found.');
+ const row = await db.maybe(sql`INSERT INTO mail_person_reports(send_id) SELECT id FROM mail_person_sends WHERE id=${req.params.id} AND status IN ('accepted','complaint') ON CONFLICT DO NOTHING RETURNING send_id`);
+ if (row) await db.exec(sql`UPDATE mail_person_sends SET status='complaint' WHERE id=${row.send_id}`);
+ return noStore(res).json({ received: true });
+ }));
+}
+
+module.exports = { mountPersonMail };
@@ -37,6 +37,10 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
@@ -37,6 +37,10 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
if (mailbox) list.push({ address: mailbox, kind: 'mailbox', label: null });
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 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 });
for (const i of inboxes) list.push({ address: i.address, kind: 'agent', label: i.agent_id || null });
+ const people = await db.many(sql`SELECT p.address,p.kind FROM mail_person_addresses p WHERE p.owner_subject=${owner} AND p.enabled=true
+ AND (p.kind<>'custom' OR EXISTS (SELECT 1 FROM mail_custom_domains d WHERE d.domain=split_part(p.address,'@',2)
+ AND d.owner_subject=p.owner_subject AND d.ownership_verified_at IS NOT NULL AND d.mx_verified_at IS NOT NULL)) ORDER BY p.created_at`);
+ for (const p of people) list.push({ address: p.address, kind: p.kind, label: null });
return { mailbox, list };
return { mailbox, list };
}
}
@@ -16,6 +16,7 @@ const { startPurgeJob } = require('./jobs/purge');
@@ -16,6 +16,7 @@ const { startPurgeJob } = require('./jobs/purge');
const { startAgentMailJob } = require('./jobs/agent-mail');
const { startAgentMailJob } = require('./jobs/agent-mail');
const { startInboxAiJob } = require('./jobs/inbox-ai');
const { startInboxAiJob } = require('./jobs/inbox-ai');
const { startImapPullJob } = require('./jobs/imap-pull');
const { startImapPullJob } = require('./jobs/imap-pull');
+const { startForwardingJob } = require('./jobs/forwarding');
async function main() {
async function main() {
const config = configLib.load();
const config = configLib.load();
@@ -56,6 +57,7 @@ async function main() {
@@ -56,6 +57,7 @@ async function main() {
// Inbox AI's other inbox: a connected mailbox is read when its own next_sync_at is due. Without MAIL_SECRETS_KEY
// Inbox AI's other inbox: a connected mailbox is read when its own next_sync_at is due. Without MAIL_SECRETS_KEY
// there is nothing to open, and the job is a no-op.
// there is nothing to open, and the job is a no-op.
const imapPull = startImapPullJob({ db, config, log });
const imapPull = startImapPullJob({ db, config, log });
+ const forwarding = startForwardingJob({ db, stalwart, config, log });
const { app } = await createApp({ config, store, stalwart, events, log });
const { app } = await createApp({ config, store, stalwart, events, log });
@@ -69,6 +71,7 @@ async function main() {
@@ -69,6 +71,7 @@ async function main() {
agentMail.stop();
agentMail.stop();
inboxAi.stop();
inboxAi.stop();
imapPull.stop();
imapPull.stop();
+ forwarding.stop();
events.stop();
events.stop();
server.close(() => {
server.close(() => {
store.close().finally(() => process.exit(0));
store.close().finally(() => process.exit(0));
@@ -78,7 +81,7 @@ async function main() {
@@ -78,7 +81,7 @@ async function main() {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
- return { server, store, stalwart, events, purge, agentMail, inboxAi, imapPull };
+ return { server, store, stalwart, events, purge, agentMail, inboxAi, imapPull, forwarding };
}
}
if (require.main === module) {
if (require.main === module) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+
+const { createNetworkPolicy } = require('../domain/network-policy');
+const { run } = require('../domain/forwarding');
+
+function startForwardingJob({ db, stalwart, config, log = console, fetchImpl = globalThis.fetch, intervalMs = 60_000 }) {
+ const policy = createNetworkPolicy(config, fetchImpl);
+ let timer;
+ let stopped = false;
+ async function tick() {
+ if (stopped) return;
+ try { await run({ db, stalwart, config, policy, fetchImpl }); }
+ catch { (log.warn || log.log).call(log, '[mail] forwarding run failed'); }
+ if (!stopped) { timer = setTimeout(tick, intervalMs); timer.unref?.(); }
+ }
+ timer = setTimeout(tick, intervalMs);
+ timer.unref?.();
+ return { stop() { stopped = true; clearTimeout(timer); }, runNow: tick };
+}
+
+module.exports = { startForwardingJob };
@@ -19,13 +19,15 @@ async function startNetwork() {
@@ -19,13 +19,15 @@ async function startNetwork() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
- const st = { users: new Map(), requests: [], down: false };
+ const st = { users: new Map(), requests: [], down: false, trust: new Map(), verifiedEmails: new Map() };
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
- function addUser(username, { role = 'user' } = {}) {
+ function addUser(username, { role = 'user', ageDays = 90, emailVerified = true, verifiedEmails = [] } = {}) {
const u = { id: nextUserId++, subject: ids.newId('user'), username, role };
const u = { id: nextUserId++, subject: ids.newId('user'), username, role };
st.users.set(u.id, u);
st.users.set(u.id, u);
+ st.trust.set(u.subject, { kind: 'account', account_age_hours: ageDays * 24, account_age_days: ageDays, email_verified: emailVerified, verified: ageDays >= 30 });
+ st.verifiedEmails.set(u.subject, new Set(verifiedEmails.map((email) => email.toLowerCase())));
return u;
return u;
}
}
@@ -63,6 +65,15 @@ async function startNetwork() {
@@ -63,6 +65,15 @@ async function startNetwork() {
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (st.down) return json(503, { error: 'network is restarting' });
if (st.down) return json(503, { error: 'network is restarting' });
+ if (url.pathname === '/oauth/token' && req.method === 'POST') return json(200, { access_token: 'test-network-service', expires_in: 300, scope: 'identity.trust.read identity.emails.read' });
+ if (req.headers.authorization === 'Bearer test-network-service' && url.pathname.startsWith('/api/v1/trust/')) {
+ const trust = st.trust.get(decodeURIComponent(url.pathname.slice('/api/v1/trust/'.length)));
+ return json(trust ? 200 : 404, trust || { error: 'not found' });
+ }
+ if (req.headers.authorization === 'Bearer test-network-service' && url.pathname === '/api/v1/identity/emails/verified-check' && req.method === 'POST') {
+ let input = {}; try { input = JSON.parse(raw.toString('utf8')); } catch { /* invalid means unverified */ }
+ return json(200, { verified: !!st.verifiedEmails.get(input.subject)?.has(String(input.email || '').toLowerCase()) });
+ }
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { boot } = require('./helpers/boot');
+const person = require('../server/domain/person-mail');
+const domains = require('../server/domain/custom-domains');
+const providers = require('../server/domain/outbound-providers');
+const providerDomains = require('../server/domain/provider-domains');
+const { sql } = require('openvibe-sdk/db');
+
+test('daily allowance grows with trusted history and pauses on bounce or complaint rates', () => {
+ const signals = { kind: 'account', email_verified: true, verified: true, account_age_hours: 90*24, account_age_days: 90 };
+ assert.equal(person.dailyLimit({ ...signals, account_age_hours: 23 }, 100), 0);
+ assert.equal(person.dailyLimit({ ...signals, email_verified: false }, 100), 0);
+ assert.equal(person.dailyLimit({ ...signals, account_age_days: 3 }, 0), 20);
+ assert.equal(person.dailyLimit({ ...signals, account_age_days: 30 }, 20), 50);
+ assert.equal(person.dailyLimit(signals, 100), 100);
+ assert.match(person.paused([{ status: 'complaint' }, ...Array(9).fill({ status: 'accepted' })]), /complaint/);
+ assert.match(person.paused([{ status: 'bounced' }, ...Array(9).fill({ status: 'accepted' })]), /bounced/);
+ assert.equal(person.paused(Array(9).fill({ status: 'accepted' })), null);
+});
+
+test('person send checks trust, recipient cap and concurrent daily allowance', async (t) => {
+ const smtp = { sendMessage: async () => ({ ok: true, code: 250 }) };
+ const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' }, smtp });
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice', { ageDays: 3 });
+ const made = await s.post('/api/v1/me/mail/person-addresses', { as: alice, json: { name: 'alice', kind: 'name' } });
+ assert.equal(made.status, 201, made.text);
+ const appToken = s.network.signJwt({ iss: s.network.url, sub: 'svc:mail-test', actor_type: 'service',
+ aud: ['openvibe.mail'], cap: ['mail.addresses.write'], on_behalf_of: alice.subject,
+ iat: Math.floor(Date.now()/1000), exp: Math.floor(Date.now()/1000)+300, jti: 'person-mail-app-1' });
+ assert.equal((await s.post('/api/v1/me/mail/send', { bearer: appToken, json: { from: made.body.address, to: ['[email protected]'], subject: 'Hello', body: 'Hi' } })).status, 403);
+ const send = (to) => s.post('/api/v1/me/mail/send', { as: alice, json: { from: made.body.address, to, subject: 'Hello', body: 'One message.' } });
+ assert.equal((await send(Array.from({ length: 11 }, (_, i) => `r${i}@openvibe.email`))).status, 422);
+ assert.equal((await send(Array.from({ length: 10 }, (_, i) => `a${i}@openvibe.email`))).status, 200);
+ assert.equal((await send(Array.from({ length: 10 }, (_, i) => `b${i}@openvibe.email`))).status, 200);
+ const over = await send(['[email protected]']);
+ assert.equal(over.status, 429, over.text);
+ assert.match(over.body.detail, /20/);
+});
+
+test('forwarding refuses unverified targets and accepts only Network verified account emails', async (t) => {
+ const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' } });
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const mask = await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
+ assert.equal(mask.status, 201);
+ const unverified = await s.post('/api/v1/me/mail/forwards', { as: alice, json: { source: mask.body.mask.address, target: '[email protected]' } });
+ assert.equal(unverified.status, 422);
+ s.network.state.verifiedEmails.get(alice.subject).add('[email protected]');
+ const verified = await s.post('/api/v1/me/mail/forwards', { as: alice, json: { source: mask.body.mask.address, target: '[email protected]' } });
+ assert.equal(verified.status, 201, verified.text);
+ const rows = await s.get('/api/v1/me/mail/forwards', { as: alice });
+ assert.equal(rows.body.forwards.length, 1);
+});
+
+test('custom domain advances only after ownership, MX and DKIM are verified', async (t) => {
+ const s = await boot({ env: { MAIL_CUSTOM_DOMAIN_MX: 'mail.openvibe.email', MAIL_PROVIDER_KEK: Buffer.alloc(32, 3).toString('base64') } });
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ await s.post('/api/v1/masks', { as: alice, json: { label: 'shop' } });
+ const added = await domains.add({ db: s.db, config: s.config, owner: alice.subject, domain: 'alice.example' });
+ assert.equal(added.status, 'pending_ownership');
+ const token = added.dns.ownership.value;
+ const lookupTxt = async (host) => host === '_openvibe-mail.alice.example' ? [[token]] : host === 's1._domainkey.alice.example' ? [['v=DKIM1; p=test']] : [];
+ const lookupMx = async () => [{ exchange: 'mail.openvibe.email', priority: 10 }];
+ let checked = await domains.verify({ db: s.db, config: s.config, owner: alice.subject, domain: 'alice.example', lookupTxt: async () => [], lookupMx });
+ assert.equal(checked.status, 'pending_ownership');
+ checked = await domains.verify({ db: s.db, config: s.config, owner: alice.subject, domain: 'alice.example', lookupTxt, lookupMx: async () => [] });
+ assert.equal(checked.status, 'pending_mx');
+ await providers.save({ db: s.db, config: s.config, input: { id: 'smtp_domain', name: 'SMTP', host: 'smtp.example', enabled: true, free_monthly: 100, monthly_budget: 1, password: 'secret' } });
+ await providerDomains.save({ db: s.db, providerId: 'smtp_domain', input: { domain: 'alice.example', dkim_records: [{ type: 'TXT', name: 's1._domainkey.alice.example', value: 'v=DKIM1; p=test' }] } });
+ checked = await domains.verify({ db: s.db, config: s.config, stalwart: { ensureDomain: async () => true }, owner: alice.subject, domain: 'alice.example', lookupTxt, lookupMx });
+ assert.equal(checked.status, 'active', JSON.stringify(checked));
+});
+
+test('pending custom domains cannot squat on another owner or receive their mail', async (t) => {
+ const s = await boot({ env: { MAIL_CUSTOM_DOMAIN_MX: 'mail.openvibe.email' } });
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice');
+ const bob = s.network.addUser('bob');
+ const claim = (owner, domain) => domains.add({ db: s.db, config: s.config, owner: owner.subject, domain });
+
+ const squatter = await claim(alice, 'gmail.com');
+ const other = await claim(bob, 'gmail.com');
+ assert.equal(squatter.status, 'pending_ownership');
+ assert.equal(other.status, 'pending_ownership');
+ assert.notEqual(squatter.dns.ownership.value, other.dns.ownership.value);
+
+ const first = await claim(alice, 'shared.example');
+ const second = await claim(bob, 'shared.example');
+ assert.notEqual(first.dns.ownership.value, second.dns.ownership.value);
+ assert.equal((await claim(alice, 'shared.example')).dns.ownership.value, first.dns.ownership.value);
+ assert.equal(await s.db.value('SELECT count(*)::int FROM mail_custom_domains WHERE domain=$1', ['shared.example']), 2);
+
+ // A stale or forged pending address must not enter Stalwart's inbound directory.
+ await s.db.exec(sql`INSERT INTO mail_person_addresses(address,owner_subject,kind) VALUES('[email protected]',${bob.subject},'custom')`);
+ assert.equal(await s.db.value('SELECT count(*)::int FROM mail_directory_accounts WHERE address=$1', ['[email protected]']), 0);
+
+ const dns = { lookupTxt: async () => [[first.dns.ownership.value], [second.dns.ownership.value]],
+ lookupMx: async () => [{ exchange: 'mail.openvibe.email', priority: 10 }] };
+ const verified = await domains.verify({ db: s.db, config: s.config, owner: alice.subject, domain: 'shared.example', ...dns });
+ assert.equal(verified.status, 'pending_dkim');
+ const address = await person.createAddress({ db: s.db, config: s.config, owner: alice.subject, local: 'alice', domain: 'shared.example', kind: 'custom' });
+ assert.equal(await s.db.value('SELECT owner FROM mail_directory_accounts WHERE address=$1', [address]), alice.subject);
+ assert.equal(await s.db.value('SELECT count(*)::int FROM mail_directory_accounts WHERE address=$1', ['[email protected]']), 0);
+
+ await assert.rejects(domains.verify({ db: s.db, config: s.config, owner: bob.subject, domain: 'shared.example', ...dns }),
+ { message: 'This domain is already in use on OpenVibe.' });
+ await assert.rejects(claim(bob, 'shared.example'), { message: 'This domain is already in use on OpenVibe.' });
+ await assert.rejects(s.db.exec(sql`UPDATE mail_custom_domains SET ownership_verified_at=now() WHERE domain='shared.example' AND owner_subject=${bob.subject}`),
+ (err) => err.code === '23505' && err.constraint === 'mail_custom_domains_verified_domain');
+ assert.equal(await s.db.value('SELECT owner_subject FROM mail_custom_domains WHERE domain=$1 AND ownership_verified_at IS NOT NULL', ['shared.example']), alice.subject);
+ await s.db.exec(sql`UPDATE mail_custom_domains SET dkim_verified_at=now() WHERE domain='shared.example' AND owner_subject=${alice.subject}`);
+ assert.equal(await person.senderAllowed(s.db, alice.subject, address), true);
+ assert.equal(await person.senderAllowed(s.db, bob.subject, '[email protected]'), false);
+});
@@ -75,10 +75,11 @@ test('domain status is normalized and SES signatures cover SigV4 canonical data'
@@ -75,10 +75,11 @@ test('domain status is normalized and SES signatures cover SigV4 canonical data'
assert.match(signed.authorization, /SignedHeaders=content-type;host;x-amz-date/);
assert.match(signed.authorization, /SignedHeaders=content-type;host;x-amz-date/);
assert.equal(normalizeWebhook('resend', { type: 'email.bounced', to: '[email protected]' }).kind, 'hard_bounce');
assert.equal(normalizeWebhook('resend', { type: 'email.bounced', to: '[email protected]' }).kind, 'hard_bounce');
assert.deepEqual(normalizeWebhook('ses', { notificationType: 'Bounce', Bounce: { bouncedRecipients: [{ emailAddress: '[email protected]' }] } }), {
assert.deepEqual(normalizeWebhook('ses', { notificationType: 'Bounce', Bounce: { bouncedRecipients: [{ emailAddress: '[email protected]' }] } }), {
- adapter: 'ses', event: 'bounce', recipient: '[email protected]', kind: 'hard_bounce',
+ adapter: 'ses', event: 'bounce', recipient: '[email protected]', messageId: null, kind: 'hard_bounce',
});
});
assert.deepEqual(normalizeWebhook('mailersend', { type: 'activity.spam_complaint', data: { recipient: '[email protected]' } }), {
assert.deepEqual(normalizeWebhook('mailersend', { type: 'activity.spam_complaint', data: { recipient: '[email protected]' } }), {
- adapter: 'mailersend', event: 'activity.spam_complaint', recipient: '[email protected]', kind: 'complaint',
+ adapter: 'mailersend', event: 'activity.spam_complaint', recipient: '[email protected]', messageId: null, kind: 'complaint',
});
});
+ assert.equal(normalizeWebhook('resend', { type: 'email.bounced', data: { to: '[email protected]', email_id: 'provider-message' } }).messageId, 'provider-message');
assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');
assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');
});
});