openvibers/openvibe.mail Commits

Add person mail sending, verified forwarding and custom domains (#29)

OP OpenVibers committed 5 days ago · 4ca33c3202 · 23 files, +803 −27

* Add person mail sending, verified forwarding and custom domains

* Cover webhook provider message IDs

* Require person sign-in for sending and forwarding

* Keep inbox readable on phones with counted settings fold

* Allow concurrent pending custom-domain claims until TXT verification

Changes

ddiff --git a/.env.example b/.env.example
iindex 230bf59..c1aa302 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.1
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_RELAY_HOST=
#MAIL_RELAY_PORT=587
ddiff --git a/README.md b/README.md
iindex 1aa456a..d01fcbe 100644
---- a/README.md
++++ b/README.md
@@@ -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
[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
ddiff --git a/docs/DNS.md b/docs/DNS.md
iindex 26074a0..fec7fdf 100644
---- a/docs/DNS.md
++++ b/docs/DNS.md
@@@ -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 not
send: until M2 there is no DKIM key and no sending path, so SPF and DMARC are strict and closed.
ddiff --git a/migrations/0013_person_mail.sql b/migrations/0013_person_mail.sql
nnew file mode 100644
iindex 0000000..6d7cdd0
---- /dev/null
++++ b/migrations/0013_person_mail.sql
@@@ -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));
ddiff --git a/package-lock.json b/package-lock.json
iindex 28cb672..c9696bb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 9b78b2f..ba1fd2e 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/public/css/inbox.css b/public/css/inbox.css
iindex b315722..9a9e1ed 100644
---- a/public/css/inbox.css
++++ b/public/css/inbox.css
@@@ -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); }
ddiff --git a/public/js/inbox.js b/public/js/inbox.js
iindex e2e3d5c..a0e094a 100644
---- a/public/js/inbox.js
++++ b/public/js/inbox.js
@@@ -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 on
aiBox.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);
ddiff --git a/server/config.js b/server/config.js
iindex afbb2a9..b3177b9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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(/\/+$/, ''),
ddiff --git a/server/domain/custom-domains.js b/server/domain/custom-domains.js
nnew file mode 100644
iindex 0000000..e420b23
---- /dev/null
++++ b/server/domain/custom-domains.js
@@@ -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 };
ddiff --git a/server/domain/forwarding.js b/server/domain/forwarding.js
nnew file mode 100644
iindex 0000000..9d40ada
---- /dev/null
++++ b/server/domain/forwarding.js
@@@ -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 };
ddiff --git a/server/domain/network-policy.js b/server/domain/network-policy.js
nnew file mode 100644
iindex 0000000..205f59c
---- /dev/null
++++ b/server/domain/network-policy.js
@@@ -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 };
ddiff --git a/server/domain/person-mail.js b/server/domain/person-mail.js
nnew file mode 100644
iindex 0000000..cc4ea53
---- /dev/null
++++ b/server/domain/person-mail.js
@@@ -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 };
ddiff --git a/server/domain/provider-adapters.js b/server/domain/provider-adapters.js
iindex 06db74a..da0c1ec 100644
---- a/server/domain/provider-adapters.js
++++ b/server/domain/provider-adapters.js
@@@ -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
ses: {
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-email
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); },
},
// 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-email
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'); },
},
// 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-api
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); },
},
};
@@@ -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) {
ddiff --git a/server/domain/provider-webhooks.js b/server/domain/provider-webhooks.js
iindex 56b81bf..276fd3e 100644
---- a/server/domain/provider-webhooks.js
++++ b/server/domain/provider-webhooks.js
@@@ -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`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 4e14a2c..4e80198 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 } : {}) });
ddiff --git a/server/http/person-routes.js b/server/http/person-routes.js
nnew file mode 100644
iindex 0000000..4899dc0
---- /dev/null
++++ b/server/http/person-routes.js
@@@ -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 };
ddiff --git a/server/http/reading-routes.js b/server/http/reading-routes.js
iindex 96baa07..455d484 100644
---- a/server/http/reading-routes.js
++++ b/server/http/reading-routes.js
@@@ -37,6 +37,10 @@ function mountReading(router, { db, stalwart, config, smtp, principal, wrap, fai
if (mailbox) list.push({ address: mailbox, kind: 'mailbox', label: null });
for (const m of maskRows) list.push({ address: m.address, kind: 'mask', label: m.label || null, enabled: m.enabled !== false });
for (const i of inboxes) list.push({ address: i.address, kind: 'agent', label: i.agent_id || null });
++ 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 };
}
ddiff --git a/server/index.js b/server/index.js
iindex 1a2d56a..c178e90 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) {
ddiff --git a/server/jobs/forwarding.js b/server/jobs/forwarding.js
nnew file mode 100644
iindex 0000000..c63d4b8
---- /dev/null
++++ b/server/jobs/forwarding.js
@@@ -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 };
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex 7451bde..bef12fa 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -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' });
});
});
ddiff --git a/test/person-mail.test.js b/test/person-mail.test.js
nnew file mode 100644
iindex 0000000..319a5bf
---- /dev/null
++++ b/test/person-mail.test.js
@@@ -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);
++});
ddiff --git a/test/provider-adapters.test.js b/test/provider-adapters.test.js
iindex 013699d..6f80573 100644
---- a/test/provider-adapters.test.js
++++ b/test/provider-adapters.test.js
@@@ -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');
});