openvibers/openvibe.mail Commits

Add owner managed outbound SMTP routing (#27)

OP OpenVibers committed 5 days ago · a8da3f8177 · 16 files, +1214 −6

* Add owner managed outbound SMTP routing

* Reserve Mail migration after IMAP

* Add provider adapters for managed outbound mail

* Add cost and health aware provider routing

* Verify provider webhooks and track delivery events

* Merge current main with IMAP inbox support

* Complete outbound provider owner controls

* Provider webhooks: read SNS's text/plain body and confirm a verified SES subscription

Amazon SNS posts its JSON with Content-Type text/plain, so the route's JSON parser left
the body empty and every SES bounce and complaint failed verification. And nothing
confirmed the SNS subscription, so it never became active. A signed
SubscriptionConfirmation for the configured topic is now confirmed by fetching its
SubscribeURL, only at https://sns.<region>.amazonaws.com with Action=ConfirmSubscription
for the same TopicArn, no redirects.

Changes

ddiff --git a/migrations/0012_outbound_providers.sql b/migrations/0012_outbound_providers.sql
nnew file mode 100644
iindex 0000000..b2bd703
---- /dev/null
++++ b/migrations/0012_outbound_providers.sql
@@@ -0,0 +1,95 @@
++-- phase: expand
++-- Owner-managed outbound SMTP providers. Credentials are ciphertext produced by the service KEK.
++CREATE TABLE IF NOT EXISTS mail_outbound_providers (
++ id text PRIMARY KEY,
++ adapter text NOT NULL DEFAULT 'smtp',
++ settings jsonb NOT NULL DEFAULT '{}'::jsonb,
++ name text NOT NULL,
++ host text NOT NULL,
++ port integer NOT NULL CHECK (port BETWEEN 1 AND 65535),
++ username text,
++ credential_ciphertext text,
++ credential_nonce text,
++ credential_tag text,
++ webhook_secret_ciphertext text,
++ webhook_secret_nonce text,
++ webhook_secret_tag text,
++ secure boolean NOT NULL DEFAULT false,
++ starttls boolean NOT NULL DEFAULT true,
++ enabled boolean NOT NULL DEFAULT false,
++ priority integer NOT NULL DEFAULT 100,
++ free_monthly integer NOT NULL DEFAULT 0 CHECK (free_monthly >= 0),
++ free_daily integer NOT NULL DEFAULT 0 CHECK (free_daily >= 0),
++ price_per_thousand numeric(12,6) NOT NULL DEFAULT 0 CHECK (price_per_thousand >= 0),
++ monthly_budget numeric(12,4) NOT NULL DEFAULT 0 CHECK (monthly_budget >= 0),
++ rate_limit_per_minute integer NOT NULL DEFAULT 60 CHECK (rate_limit_per_minute BETWEEN 1 AND 100000),
++ enabled_kinds jsonb NOT NULL DEFAULT '["system","person"]'::jsonb,
++ health_failures integer NOT NULL DEFAULT 0 CHECK (health_failures >= 0),
++ health_open_until timestamptz,
++ health_probe_until timestamptz,
++ last_health_at timestamptz,
++ last_health text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_usage (
++ provider_id text NOT NULL REFERENCES mail_outbound_providers(id) ON DELETE CASCADE,
++ month date NOT NULL,
++ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
++ sent integer NOT NULL DEFAULT 0 CHECK (sent >= 0),
++ spend numeric(12,6) NOT NULL DEFAULT 0 CHECK (spend >= 0),
++ PRIMARY KEY (provider_id, month)
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_daily_usage (
++ provider_id text NOT NULL REFERENCES mail_outbound_providers(id) ON DELETE CASCADE,
++ day date NOT NULL,
++ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
++ sent integer NOT NULL DEFAULT 0 CHECK (sent >= 0),
++ PRIMARY KEY (provider_id, day)
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_rate_usage (
++ provider_id text NOT NULL REFERENCES mail_outbound_providers(id) ON DELETE CASCADE,
++ minute_bucket timestamptz NOT NULL,
++ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
++ PRIMARY KEY (provider_id, minute_bucket)
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_suppressions (
++ address text PRIMARY KEY,
++ reason text NOT NULL CHECK (reason IN ('hard_bounce','complaint','owner_block')),
++ source_provider text,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_deliverability (
++ provider_id text NOT NULL REFERENCES mail_outbound_providers(id) ON DELETE CASCADE,
++ recipient_domain text NOT NULL,
++ delivered integer NOT NULL DEFAULT 0 CHECK (delivered >= 0),
++ bounced integer NOT NULL DEFAULT 0 CHECK (bounced >= 0),
++ complaints integer NOT NULL DEFAULT 0 CHECK (complaints >= 0),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (provider_id, recipient_domain)
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_webhook_events (
++ provider_id text NOT NULL REFERENCES mail_outbound_providers(id) ON DELETE CASCADE,
++ event_id text NOT NULL,
++ recipient text NOT NULL,
++ recipient_domain text NOT NULL,
++ kind text NOT NULL,
++ received_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (provider_id, event_id)
++);
++
++CREATE TABLE IF NOT EXISTS mail_outbound_domains (
++ provider_id text NOT NULL REFERENCES mail_outbound_providers(id) ON DELETE CASCADE,
++ domain text NOT NULL,
++ dkim_records jsonb NOT NULL DEFAULT '[]'::jsonb,
++ return_path text,
++ spf_include text,
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY(provider_id, domain)
++);
ddiff --git a/public/js/mail-providers.js b/public/js/mail-providers.js
nnew file mode 100644
iindex 0000000..c415a40
---- /dev/null
++++ b/public/js/mail-providers.js
@@@ -0,0 +1,59 @@
++(function () {
++ 'use strict';
++ var root = document.querySelector('[data-mail-provider-admin]');
++ if (!root) return;
++ var list = root.querySelector('[data-provider-list]');
++ var form = root.querySelector('[data-provider-form]');
++ var domainForm = root.querySelector('[data-domain-form]');
++ var domainProviders = root.querySelector('[data-domain-providers]');
++ var domainStatus = root.querySelector('[data-domain-status]');
++ var status = root.querySelector('[data-status]');
++ var error = root.querySelector('[data-error]');
++ function say(text) { error.textContent = text || ''; error.hidden = !text; }
++ function request(url, options) {
++ return fetch(url, Object.assign({ credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }, options || {}))
++ .then(function (r) { return r.json().catch(function () { return {}; }).then(function (body) { if (!r.ok) throw new Error(body.detail || 'The provider request failed.'); return body; }); });
++ }
++ function load() {
++ request('/api/v1/admin/mail/providers').then(function (data) {
++ status.textContent = data.outbound_enabled ? 'Outbound delivery is enabled by MAIL_OUTBOUND.' : 'Outbound delivery is closed by MAIL_OUTBOUND.';
++ list.replaceChildren();
++ domainProviders.replaceChildren();
++ (data.providers || []).forEach(function (provider) {
++ var option = document.createElement('option'); option.value = provider.id; option.textContent = provider.name + (provider.enabled ? '' : ' (disabled)'); domainProviders.append(option);
++ var card = document.createElement('article');
++ var title = document.createElement('h3'); title.textContent = provider.name;
++ var detail = document.createElement('p'); detail.textContent = provider.adapter + ' · ' + (provider.enabled ? 'enabled' : 'disabled') + ' · sent today ' + provider.sent_today + ', this month ' + provider.sent_this_month + ' · free left ' + (provider.free_left_today === null ? 'unlimited today' : provider.free_left_today + ' today') + ' / ' + provider.free_left_this_month + ' this month · spend $' + Number(provider.spend_this_month).toFixed(4) + ' / $' + Number(provider.monthly_budget).toFixed(2) + ' · bounce ' + (Number(provider.bounce_rate) * 100).toFixed(2) + '% · complaints ' + (Number(provider.complaint_rate) * 100).toFixed(3) + '% · health ' + provider.health + ' · rate ' + provider.rate_limit_per_minute + '/min · ' + (provider.has_credential ? 'credential saved' : 'no credential') + ' · webhook: ' + window.location.origin + '/api/v1/mail/provider-webhooks/' + provider.id + (provider.has_webhook_secret ? ' (signing secret saved)' : ' (configure provider authentication)');
++ var remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Remove';
++ remove.addEventListener('click', function () { request('/api/v1/admin/mail/providers/' + encodeURIComponent(provider.id), { method: 'DELETE' }).then(load).catch(function (e) { say(e.message); }); });
++ card.append(title, detail, remove); list.append(card);
++ });
++ if (!data.providers || !data.providers.length) list.textContent = 'No outbound providers are configured.';
++ }).catch(function (e) { status.textContent = ''; say(e.message); });
++ }
++ form.addEventListener('submit', function (event) {
++ event.preventDefault(); say('');
++ var values = new FormData(form); var id = 'smtp_' + crypto.randomUUID();
++ var body = { adapter: values.get('adapter'), settings: {}, name: values.get('name'), host: values.get('host'), port: Number(values.get('port')), username: values.get('username'), password: values.get('password'), webhook_secret: values.get('webhook_secret'), free_daily: Number(values.get('free_daily')), free_monthly: Number(values.get('free_monthly')), price_per_thousand: Number(values.get('price_per_thousand')), monthly_budget: Number(values.get('monthly_budget')), rate_limit_per_minute: Number(values.get('rate_limit_per_minute')), enabled_kinds: Array.from(form.querySelectorAll('[name="kind"]:checked')).map(function (input) { return input.value; }), priority: Number(values.get('priority')), secure: form.elements.secure.checked, starttls: form.elements.starttls.checked, enabled: form.elements.enabled.checked };
++ try { body.settings = JSON.parse(values.get('settings_json') || '{}'); } catch (_) { say('Provider settings must be valid JSON.'); return; }
++ request('/api/v1/admin/mail/providers/' + encodeURIComponent(id), { method: 'PUT', body: JSON.stringify(body) }).then(function () { form.reset(); form.elements.port.value = '587'; form.elements.starttls.checked = true; status.textContent = 'Provider saved. Credentials will not be shown again.'; load(); }).catch(function (e) { say(e.message); });
++ });
++ domainForm.addEventListener('submit', function (event) {
++ event.preventDefault(); say('');
++ var values = new FormData(domainForm); var records;
++ try { records = JSON.parse(values.get('dkim_records') || '[]'); } catch (_) { say('DNS records must be valid JSON.'); return; }
++ var body = { domain: values.get('domain'), dkim_records: records, return_path: values.get('return_path'), spf_include: values.get('spf_include') };
++ request('/api/v1/admin/mail/domains/' + encodeURIComponent(values.get('provider_id')), { method: 'PUT', body: JSON.stringify(body) }).then(renderDomains).catch(function (e) { say(e.message); });
++ });
++ function renderDomains(data) {
++ domainStatus.replaceChildren();
++ var heading = document.createElement('h3'); heading.textContent = 'DNS setup for ' + data.domain;
++ var spf = document.createElement('p'); spf.textContent = 'SPF: ' + data.spf + ' · lookup count ' + data.lookup_count + '/10' + (data.lookup_count_uncertain ? ' (some provider SPF records could not be resolved)' : '');
++ domainStatus.append(heading, spf);
++ if (data.warn_before_limit) { var warning = document.createElement('p'); warning.textContent = 'SPF is near the 10 DNS lookup limit or its provider records could not be counted.'; domainStatus.append(warning); }
++ (data.records || []).forEach(function (record) {
++ var card = document.createElement('p'); card.textContent = record.name + ' (' + (record.priority ? 'MX ' + record.priority : record.type) + '): ' + record.value + (record.return_path ? ' · return-path ' + record.return_path : ''); domainStatus.append(card);
++ });
++ }
++ load();
++}());
ddiff --git a/server/app.js b/server/app.js
iindex 3cd7a45..26d68f0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -70,6 +70,8 @@ async function createApp(opts = {}) {
app.use(contracts.http.middleware());
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
++ // Amazon SNS posts its JSON as text/plain; every other provider sends application/json.
++ app.use('/api/v1/mail/provider-webhooks', express.json({ type: ['application/json', 'text/plain'], limit: '256kb', verify: (req, _res, buffer) => { req.rawBody = Buffer.from(buffer); } }));
app.use(express.json({ limit: '16kb' }));
// Nothing here is cacheable: a person's addresses and sign-in are private, and a machine answer is live.
app.use(['/api', '/auth'], (_req, res, next) => {
@@@ -117,7 +119,7 @@ async function createApp(opts = {}) {
}));
app.use('/api/v1', principal.middleware);
-- app.use(createApi({ db: store.db, config, stalwart, events, principal, log, smtp: opts.smtp || null, imap: opts.imap || null }));
++ app.use(createApi({ db: store.db, config, stalwart, events, principal, log, smtp: opts.smtp || null, fetchImpl, imap: opts.imap || null }));
// The agent's own system (track HO6): one letter in full text and the reply to it, loopback only under a
// first-party service token; and the person's own answering switch and block list.
ddiff --git a/server/config.js b/server/config.js
iindex df7d646..237ded5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -139,6 +139,7 @@ function load(overrides = null) {
// it off Mail sends only to its own domains over loopback, and a reply to anyone else never opens a socket.
outbound: {
enabled: /^(1|true|on|yes)$/i.test(String(env.MAIL_OUTBOUND || '').trim()),
++ encryptionKey: env.MAIL_PROVIDER_KEK || '',
},
// The outbound relay, read only when outbound is switched on. Until it exists, a reply to an address outside
ddiff --git a/server/domain/inbox-ai-send.js b/server/domain/inbox-ai-send.js
iindex ed951de..777a0f2 100644
---- a/server/domain/inbox-ai-send.js
++++ b/server/domain/inbox-ai-send.js
@@@ -3,6 +3,7 @@
const { sql } = require('openvibe-sdk/db');
const sending = require('./sending');
const reply = require('./agent-reply');
++const providers = require('./outbound-providers');
function senderAddress(from) {
const value = Array.isArray(from) ? String(from[0] || '') : String(from || '');
@@@ -34,7 +35,9 @@ async function sendInboxDraft({ db, config, stalwart, smtp, owner, threadId, bod
if (!String(text || '').trim() || String(text).length > 10000 || String(title || '').length > 2000 || /[\r\n\u0000]/.test(String(title || ''))) {
return { status: 422, code: 'mail.ai_draft.invalid' };
}
-- const route = sending.routeFor(to, config);
++ let route = sending.routeFor(to, config);
++ const managed = !sending.isLocal(to, config) && config.outbound && config.outbound.enabled ? await providers.list(db) : [];
++ if (managed.length) route = { kind: 'relay', providerManaged: true };
if (!route) return { status: 409, code: 'mail.outbound_closed', detail: sending.OUTBOUND_CLOSED };
const claimed = await db.maybe(sql`UPDATE mail_inbox_ai_threads SET draft_state = 'sending',
draft_body = ${String(text)}, draft_subject = ${String(title || '')}, updated_at = now()
@@@ -47,7 +50,9 @@ async function sendInboxDraft({ db, config, stalwart, smtp, owner, threadId, bod
messageId, inReplyTo: headers.messageId || null,
references: [...(headers.references || []), headers.messageId].filter(Boolean), at: now() });
const mailer = smtp || require('../stalwart/smtp');
-- const result = await mailer.sendMessage(route, { from: mailbox.address, to, message });
++ const result = route.providerManaged
++ ? await providers.sendWithFailover({ db, config, from: mailbox.address, to, message, kind: 'person', smtp: mailer, now: new Date(now()) })
++ : await mailer.sendMessage(route, { from: mailbox.address, to, message });
if (!result || !result.ok) {
// Do not retry automatically; a user may explicitly click send again after seeing the failure.
await db.exec(sql`UPDATE mail_inbox_ai_threads SET draft_state = 'draft', updated_at = now()
ddiff --git a/server/domain/outbound-providers.js b/server/domain/outbound-providers.js
nnew file mode 100644
iindex 0000000..1fdb401
---- /dev/null
++++ b/server/domain/outbound-providers.js
@@@ -0,0 +1,249 @@
++'use strict';
++
++const crypto = require('crypto');
++const { sql } = require('openvibe-sdk/db');
++const adapters = require('./provider-adapters');
++
++function keyFrom(config) {
++ const key = config.outbound && config.outbound.encryptionKey;
++ return key ? crypto.createHash('sha256').update(key).digest() : null;
++}
++
++function seal(secret, key) {
++ if (!key) throw new Error('MAIL_PROVIDER_KEK must be set before storing provider credentials.');
++ const nonce = crypto.randomBytes(12);
++ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
++ const ciphertext = Buffer.concat([cipher.update(String(secret), 'utf8'), cipher.final()]);
++ return { ciphertext: ciphertext.toString('base64'), nonce: nonce.toString('base64'), tag: cipher.getAuthTag().toString('base64') };
++}
++
++function open(row, key) {
++ if (!row.credential_ciphertext) return '';
++ if (!key) throw new Error('MAIL_PROVIDER_KEK is not configured.');
++ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(row.credential_nonce, 'base64'));
++ decipher.setAuthTag(Buffer.from(row.credential_tag, 'base64'));
++ return Buffer.concat([decipher.update(Buffer.from(row.credential_ciphertext, 'base64')), decipher.final()]).toString('utf8');
++}
++
++function publicProvider(row) {
++ return {
++ id: row.id, adapter: row.adapter || 'smtp', settings: row.settings || {}, name: row.name, host: row.host, port: row.port, username: row.username || null,
++ has_credential: Boolean(row.credential_ciphertext), has_webhook_secret: Boolean(row.webhook_secret_ciphertext), secure: row.secure, starttls: row.starttls,
++ enabled: row.enabled, priority: row.priority, free_monthly: row.free_monthly,
++ price_per_thousand: Number(row.price_per_thousand), monthly_budget: Number(row.monthly_budget),
++ free_daily: Number(row.free_daily || 0), rate_limit_per_minute: Number(row.rate_limit_per_minute || 60),
++ enabled_kinds: row.enabled_kinds || ['system','person'], sent_today: Number(row.sent_today || 0),
++ sent_this_month: Number(row.sent_this_month || 0), attempts_this_month: Number(row.attempts_this_month || 0),
++ free_left_today: Number(row.free_daily || 0) ? Math.max(0, Number(row.free_daily) - Number(row.sent_today || 0)) : null,
++ free_left_this_month: Math.max(0, Number(row.free_monthly || 0) - Number(row.sent_this_month || 0)),
++ spend_this_month: Number(row.spend_this_month || 0), bounce_rate: Number(row.bounce_rate || 0), complaint_rate: Number(row.complaint_rate || 0),
++ health: row.health_open_until && new Date(row.health_open_until) > new Date() ? 'open' : row.last_health === 'degraded' ? 'degraded' : 'healthy',
++ };
++}
++
++function validate(input) {
++ const value = input || {};
++ const adapter = String(value.adapter || 'smtp');
++ const allowedAdapters = ['smtp','ses','cloudflare','brevo','mailjet','mailersend','resend','smtp2go','zeptomail','postmark'];
++ if (!allowedAdapters.includes(adapter)) throw new Error('Select a supported mail provider.');
++ let host = String(value.host || '').trim();
++ if (adapter === 'smtp' && (!/^[a-z0-9.-]{1,253}$/i.test(host) || host.startsWith('.') || host.endsWith('.'))) throw new Error('Enter a valid SMTP host.');
++ if (adapter !== 'smtp' && !host) host = `${adapter}.api.invalid`;
++ const settings = value.settings && typeof value.settings === 'object' && !Array.isArray(value.settings) ? value.settings : {};
++ const containsCredentialKey = (value) => value && typeof value === 'object' && Object.entries(value).some(([key, item]) => /(?:token|secret|password|credential|api.?key)/i.test(key) || containsCredentialKey(item));
++ if (containsCredentialKey(settings)) throw new Error('Keep credentials in the encrypted credential field.');
++ if (adapter === 'cloudflare' && !/^[a-z0-9-]{3,64}$/i.test(String(settings.account_id || ''))) throw new Error('Cloudflare needs its account_id in provider settings.');
++ if (adapter === 'ses' && !/^[a-z]{2}(?:-gov)?-[a-z]+-\d$/i.test(String(settings.region || ''))) throw new Error('Amazon SES needs a valid AWS region in provider settings.');
++ const port = Number(value.port || 587);
++ const priority = Number(value.priority ?? 100);
++ const free = Number(value.free_monthly ?? 0);
++ const price = Number(value.price_per_thousand ?? 0);
++ const budget = Number(value.monthly_budget ?? 0);
++ const freeDaily = Number(value.free_daily ?? 0);
++ const rateLimit = Number(value.rate_limit_per_minute ?? 60);
++ const kinds = Array.isArray(value.enabled_kinds) ? value.enabled_kinds : ['system','person'];
++ if (!Number.isInteger(port) || port < 1 || port > 65535 || !Number.isInteger(priority) || !Number.isInteger(free)
++ || free < 0 || !Number.isInteger(freeDaily) || freeDaily < 0 || !Number.isInteger(rateLimit) || rateLimit < 1
++ || !kinds.length || kinds.some((kind) => !['system','person'].includes(kind))
++ || !Number.isFinite(price) || price < 0 || !Number.isFinite(budget) || budget < 0) throw new Error('Provider limits and prices must be non-negative numbers, and the port must be valid.');
++ if ((price > 0 || value.enabled) && budget <= 0) throw new Error('Set a monthly budget cap before enabling paid outbound mail.');
++ return {
++ adapter,
++ settings,
++ id: String(value.id || `smtp_${crypto.randomUUID()}`).slice(0, 80),
++ name: String(value.name || host).trim().slice(0, 100), host, port, priority, free, price, budget,
++ username: value.username ? String(value.username).slice(0, 200) : null, freeDaily, rateLimit, kinds,
++ secure: value.secure === true, starttls: value.starttls !== false, enabled: value.enabled === true,
++ };
++}
++
++async function list(db) {
++ return db.many(sql`SELECT p.*, COALESCE(u.attempts, 0) AS attempts_this_month, COALESCE(u.sent, 0) AS sent_this_month,
++ COALESCE(d.attempts, 0) AS attempts_today, COALESCE(d.sent, 0) AS sent_today, COALESCE(p.health_failures,0) AS current_health_failures,
++ COALESCE(u.spend,0)::numeric(12,6) AS spend_this_month,
++ COALESCE(ds.bounced,0)::numeric / GREATEST(1,COALESCE(ds.delivered,0)+COALESCE(ds.bounced,0)) AS bounce_rate,
++ COALESCE(ds.complaints,0)::numeric / GREATEST(1,COALESCE(ds.delivered,0)+COALESCE(ds.bounced,0)) AS complaint_rate
++ FROM mail_outbound_providers p LEFT JOIN mail_outbound_usage u ON u.provider_id = p.id AND u.month = date_trunc('month', now())::date
++ LEFT JOIN mail_outbound_daily_usage d ON d.provider_id = p.id AND d.day = now()::date
++ LEFT JOIN (SELECT provider_id,SUM(delivered) delivered,SUM(bounced) bounced,SUM(complaints) complaints FROM mail_outbound_deliverability GROUP BY provider_id) ds ON ds.provider_id=p.id
++ ORDER BY p.priority, p.id`);
++}
++
++async function save({ db, config, input }) {
++ const p = validate(input);
++ const secret = input && typeof input.password === 'string' ? input.password : null;
++ const webhookSecret = input && typeof input.webhook_secret === 'string' ? input.webhook_secret : null;
++ const existing = await db.maybe(sql`SELECT credential_ciphertext FROM mail_outbound_providers WHERE id=${p.id}`);
++ if (p.adapter !== 'smtp' && p.enabled && !secret && !existing?.credential_ciphertext) throw new Error('Add the provider API credential before enabling it.');
++ const key = keyFrom(config);
++ if (secret !== null && secret && !key) throw new Error('MAIL_PROVIDER_KEK must be configured before adding provider credentials.');
++ const encrypted = secret ? seal(secret, key) : null;
++ const encryptedWebhook = webhookSecret ? seal(webhookSecret, key) : null;
++ return db.exec(sql`INSERT INTO mail_outbound_providers
++ (id,adapter,settings,name,host,port,username,credential_ciphertext,credential_nonce,credential_tag,webhook_secret_ciphertext,webhook_secret_nonce,webhook_secret_tag,secure,starttls,enabled,priority,free_daily,free_monthly,price_per_thousand,monthly_budget,rate_limit_per_minute,enabled_kinds)
++ VALUES (${p.id},${p.adapter},${JSON.stringify(p.settings)}::jsonb,${p.name},${p.host},${p.port},${p.username},${encrypted && encrypted.ciphertext},${encrypted && encrypted.nonce},${encrypted && encrypted.tag},${encryptedWebhook && encryptedWebhook.ciphertext},${encryptedWebhook && encryptedWebhook.nonce},${encryptedWebhook && encryptedWebhook.tag},${p.secure},${p.starttls},${p.enabled},${p.priority},${p.freeDaily},${p.free},${p.price},${p.budget},${p.rateLimit},${JSON.stringify(p.kinds)}::jsonb)
++ ON CONFLICT (id) DO UPDATE SET adapter=excluded.adapter,settings=excluded.settings,name=excluded.name,host=excluded.host,port=excluded.port,username=excluded.username,
++ credential_ciphertext=COALESCE(excluded.credential_ciphertext,mail_outbound_providers.credential_ciphertext),
++ credential_nonce=COALESCE(excluded.credential_nonce,mail_outbound_providers.credential_nonce),
++ credential_tag=COALESCE(excluded.credential_tag,mail_outbound_providers.credential_tag),
++ webhook_secret_ciphertext=COALESCE(excluded.webhook_secret_ciphertext,mail_outbound_providers.webhook_secret_ciphertext),
++ webhook_secret_nonce=COALESCE(excluded.webhook_secret_nonce,mail_outbound_providers.webhook_secret_nonce),
++ webhook_secret_tag=COALESCE(excluded.webhook_secret_tag,mail_outbound_providers.webhook_secret_tag), secure=excluded.secure,starttls=excluded.starttls,
++ enabled=excluded.enabled,priority=excluded.priority,free_daily=excluded.free_daily,free_monthly=excluded.free_monthly,
++ price_per_thousand=excluded.price_per_thousand,monthly_budget=excluded.monthly_budget,
++ rate_limit_per_minute=excluded.rate_limit_per_minute,enabled_kinds=excluded.enabled_kinds,updated_at=now()`);
++}
++
++async function remove(db, id) {
++ return db.exec(sql`DELETE FROM mail_outbound_providers WHERE id = ${String(id || '').slice(0,80)}`);
++}
++
++const monthDay = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)).toISOString().slice(0,10);
++const dayKey = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())).toISOString().slice(0,10);
++
++async function isSuppressed(db, address) {
++ return Boolean(await db.maybe(sql`SELECT address FROM mail_outbound_suppressions WHERE address = ${String(address || '').trim().toLowerCase()}`));
++}
++
++async function deliverability(db, providerId, recipientDomain) {
++ if (!recipientDomain) return { delivered: 0, bounced: 0, complaints: 0 };
++ return await db.maybe(sql`SELECT delivered,bounced,complaints FROM mail_outbound_deliverability WHERE provider_id = ${providerId} AND recipient_domain = ${recipientDomain}`)
++ || { delivered: 0, bounced: 0, complaints: 0 };
++}
++
++async function reserve({ db, provider, now }) {
++ return db.tx(async (t) => {
++ const locked = await t.maybe(sql`SELECT * FROM mail_outbound_providers WHERE id = ${provider.id} AND enabled = true FOR UPDATE`);
++ if (!locked) return null;
++ const halfOpen = locked.health_open_until && new Date(locked.health_open_until) <= now;
++ if (locked.health_open_until && new Date(locked.health_open_until) <= now) {
++ if (locked.health_probe_until && new Date(locked.health_probe_until) > now) return null;
++ }
++ const month = monthDay(now), day = dayKey(now), minute = new Date(Math.floor(now.getTime()/60000)*60000).toISOString();
++ await t.exec(sql`INSERT INTO mail_outbound_usage (provider_id,month) VALUES (${provider.id},${month}) ON CONFLICT DO NOTHING`);
++ await t.exec(sql`INSERT INTO mail_outbound_daily_usage (provider_id,day) VALUES (${provider.id},${day}) ON CONFLICT DO NOTHING`);
++ await t.exec(sql`INSERT INTO mail_outbound_rate_usage (provider_id,minute_bucket) VALUES (${provider.id},${minute}) ON CONFLICT DO NOTHING`);
++ await t.exec(sql`DELETE FROM mail_outbound_rate_usage WHERE provider_id=${provider.id} AND minute_bucket < ${new Date(now.getTime()-5*60_000).toISOString()}`);
++ const monthly = await t.maybe(sql`SELECT attempts FROM mail_outbound_usage WHERE provider_id=${provider.id} AND month=${month}`);
++ const daily = await t.maybe(sql`SELECT attempts FROM mail_outbound_daily_usage WHERE provider_id=${provider.id} AND day=${day}`);
++ const rate = await t.maybe(sql`SELECT attempts FROM mail_outbound_rate_usage WHERE provider_id=${provider.id} AND minute_bucket=${minute}`);
++ const monthlyAttempts = Number(monthly && monthly.attempts || 0), dailyAttempts = Number(daily && daily.attempts || 0);
++ const useFree = monthlyAttempts < Number(locked.free_monthly) && (!Number(locked.free_daily) || dailyAttempts < Number(locked.free_daily));
++ const paidBefore = Math.max(0, monthlyAttempts - Number(locked.free_monthly));
++ const paidAfter = Math.max(0, monthlyAttempts + 1 - Number(locked.free_monthly));
++ const spendAfter = paidAfter * Number(locked.price_per_thousand) / 1000;
++ if (!useFree && (Number(locked.monthly_budget) <= 0 || spendAfter > Number(locked.monthly_budget))) return null;
++ if (Number(rate && rate.attempts || 0) >= Number(locked.rate_limit_per_minute)) return null;
++ if (halfOpen) await t.exec(sql`UPDATE mail_outbound_providers SET health_probe_until=${new Date(now.getTime()+30_000).toISOString()} WHERE id=${provider.id}`);
++ await t.exec(sql`UPDATE mail_outbound_usage SET attempts=attempts+1 WHERE provider_id=${provider.id} AND month=${month}`);
++ await t.exec(sql`UPDATE mail_outbound_daily_usage SET attempts=attempts+1 WHERE provider_id=${provider.id} AND day=${day}`);
++ await t.exec(sql`UPDATE mail_outbound_rate_usage SET attempts=attempts+1 WHERE provider_id=${provider.id} AND minute_bucket=${minute}`);
++ return { free: useFree, monthlyAttempts, dailyAttempts, paidBefore };
++ });
++}
++
++async function choose({ db, config, now = new Date(), fromDomain = null, toDomain = null, toAddress = null, kind = 'person', exclude = [], fetchImpl = globalThis.fetch, lookupTxt, lookupMx }) {
++ if (!config.outbound.enabled) return null;
++ if (toAddress && await isSuppressed(db, toAddress)) return { suppressed: true };
++ const excluded = new Set(exclude);
++ const rows = await list(db);
++ const candidates = [];
++ for (const row of rows) {
++ if (!row.enabled || excluded.has(row.id) || !(row.enabled_kinds || []).includes(kind)) continue;
++ if (row.health_open_until && new Date(row.health_open_until) > now) continue;
++ if (row.health_open_until && row.health_probe_until && new Date(row.health_probe_until) > now) continue;
++ if (fromDomain) {
++ let credential = open(row, keyFrom(config));
++ try { credential = JSON.parse(credential); } catch { /* opaque provider token */ }
++ const dnsConfig = await db.maybe(sql`SELECT dkim_records FROM mail_outbound_domains WHERE provider_id=${row.id} AND domain=${fromDomain}`);
++ const dnsHosts = (dnsConfig && dnsConfig.dkim_records || []).filter((record) => record.type === 'TXT' && /_domainkey/i.test(record.name)).map((record) => record.name);
++ const settings = { ...(row.settings || {}), ...(dnsHosts.length ? { dkim_hosts: dnsHosts } : {}) };
++ const verified = await adapters.domainStatus({ route: { adapter: row.adapter, settings, password: credential }, domain: fromDomain, fetchImpl, lookupTxt, lookupMx });
++ if (!verified) continue;
++ }
++ const metrics = await deliverability(db, row.id, toDomain);
++ const deliveries = Number(metrics.delivered) + Number(metrics.bounced) + Number(metrics.complaints);
++ if (deliveries >= 10 && (Number(metrics.bounced)/deliveries >= 0.1 || Number(metrics.complaints)/deliveries >= 0.01)) continue;
++ candidates.push({ row, metrics, deliveries });
++ }
++ candidates.sort((a,b) => {
++ const freeA = Number(a.row.attempts_this_month) < Number(a.row.free_monthly), freeB = Number(b.row.attempts_this_month) < Number(b.row.free_monthly);
++ const dailyFreeA = !Number(a.row.free_daily) || Number(a.row.attempts_today) < Number(a.row.free_daily);
++ const dailyFreeB = !Number(b.row.free_daily) || Number(b.row.attempts_today) < Number(b.row.free_daily);
++ const useFreeA = freeA && dailyFreeA, useFreeB = freeB && dailyFreeB;
++ if (useFreeA !== useFreeB) return useFreeA ? -1 : 1;
++ if (useFreeA) return Number(a.row.attempts_this_month)/Math.max(1,Number(a.row.free_monthly)) - Number(b.row.attempts_this_month)/Math.max(1,Number(b.row.free_monthly)) || Number(a.row.priority)-Number(b.row.priority);
++ return Number(a.row.price_per_thousand)-Number(b.row.price_per_thousand) || Number(a.row.priority)-Number(b.row.priority);
++ });
++ let selected = null;
++ for (const candidate of candidates) {
++ const reservation = await reserve({ db, provider: candidate.row, now });
++ if (reservation) { selected = { ...candidate.row, reservation }; break; }
++ }
++ const row = selected;
++ if (!row) return null;
++ let credential = open(row, keyFrom(config));
++ try { credential = JSON.parse(credential); } catch { /* token and SMTP credentials stay strings */ }
++ return { kind: 'relay', adapter: row.adapter || 'smtp', settings: row.settings || {}, host: row.host, port: row.port, secure: row.secure, starttls: row.starttls,
++ price_per_thousand: Number(row.price_per_thousand || 0),
++ user: row.username, password: credential, timeoutMs: 20000, providerId: row.id, reservation: row.reservation };
++}
++
++async function recordOutcome({ db, providerId, to, ok, transient = false, spend = 0, now = new Date() }) {
++ const month = monthDay(now), day = dayKey(now);
++ await db.tx(async (t) => {
++ if (ok) {
++ await t.exec(sql`UPDATE mail_outbound_usage SET sent=sent+1,spend=spend+${Math.max(0,Number(spend)||0)} WHERE provider_id=${providerId} AND month=${month}`);
++ await t.exec(sql`UPDATE mail_outbound_daily_usage SET sent=sent+1 WHERE provider_id=${providerId} AND day=${day}`);
++ await t.exec(sql`UPDATE mail_outbound_providers SET health_failures=0,health_open_until=NULL,health_probe_until=NULL,last_health_at=${now.toISOString()},last_health='healthy' WHERE id=${providerId}`);
++ } else if (transient) {
++ await t.exec(sql`UPDATE mail_outbound_providers SET health_failures=health_failures+1,
++ health_open_until=CASE WHEN health_failures+1 >= 3 THEN ${new Date(now.getTime()+5*60_000).toISOString()}::timestamptz ELSE health_open_until END,
++ health_probe_until=NULL,last_health_at=${now.toISOString()},last_health='degraded' WHERE id=${providerId}`);
++ }
++ });
++}
++
++async function sendWithFailover({ db, config, from, to, message, kind = 'person', smtp, fetchImpl = globalThis.fetch, now = new Date(), lookupTxt, lookupMx }) {
++ if (await isSuppressed(db, to)) return { ok: false, suppressed: true, code: 550, reason: 'This address is suppressed after a hard bounce or complaint.' };
++ const domain = (value) => String(value || '').toLowerCase().split('@').pop();
++ const excluded = [];
++ const max = Math.min(10, (await list(db)).length);
++ for (let attempt = 0; attempt < max; attempt += 1) {
++ const route = await choose({ db, config, now, fromDomain: domain(from), toDomain: domain(to), toAddress: to, kind, exclude: excluded, fetchImpl, lookupTxt, lookupMx });
++ if (route && route.suppressed) return { ok: false, suppressed: true, code: 550, reason: 'This address is suppressed after a hard bounce or complaint.' };
++ if (!route) return { ok: false, code: 0, reason: 'No enabled provider can send from this verified domain within its free allowance, rate limit, and monthly budget.' };
++ let result = route.adapter === 'smtp'
++ ? await (smtp || require('../stalwart/smtp')).sendMessage(route, { from, to, message })
++ : await adapters.send({ route, from, to, message, fetchImpl });
++ if (route.adapter === 'smtp' && result && !result.ok) result = { ...result, transient: Number(result.code) >= 400 && Number(result.code) < 500, uncertain: !Number(result.code) };
++ const spend = route.reservation && route.reservation.free ? 0 : Number(route.price_per_thousand || 0) / 1000;
++ await recordOutcome({ db, providerId: route.providerId, to, ok: Boolean(result && result.ok), transient: Boolean(result && result.transient), spend, now });
++ if (result && result.ok) return { ...result, route };
++ if (!result || !result.transient || result.uncertain) return { ...(result || {}), ok: false, route };
++ excluded.push(route.providerId);
++ }
++ return { ok: false, code: 0, reason: 'Every eligible provider temporarily refused this message.', transient: true };
++}
++
++module.exports = { validate, list, save, remove, choose, sendWithFailover, recordOutcome, isSuppressed, publicProvider, keyFrom, seal, open };
ddiff --git a/server/domain/provider-adapters.js b/server/domain/provider-adapters.js
nnew file mode 100644
iindex 0000000..06db74a
---- /dev/null
++++ b/server/domain/provider-adapters.js
@@@ -0,0 +1,207 @@
++'use strict';
++
++const crypto = require('crypto');
++const dns = require('node:dns/promises');
++const smtp = require('../stalwart/smtp');
++
++class ProviderError extends Error {
++ constructor(message, { status = 0, transient = false, uncertain = false, code = 'provider.error' } = {}) {
++ super(message);
++ this.status = status;
++ this.transient = transient;
++ this.uncertain = uncertain;
++ this.code = code;
++ }
++}
++
++function parseMessage(raw) {
++ const [head, ...bodyParts] = String(raw || '').split(/\r?\n\r?\n/);
++ const headers = Object.create(null);
++ let last = '';
++ for (const line of String(head || '').split(/\r?\n/)) {
++ if (/^[ \t]/.test(line) && last) headers[last] += ` ${line.trim()}`;
++ else {
++ const at = line.indexOf(':');
++ if (at < 1) continue;
++ last = line.slice(0, at).trim().toLowerCase();
++ headers[last] = line.slice(at + 1).trim();
++ }
++ }
++ const address = (value) => {
++ const found = /<([^<>\s]+@[^<>\s]+)>/.exec(String(value || ''));
++ return (found ? found[1] : String(value || '')).trim();
++ };
++ return { from: address(headers.from), to: address(headers.to), subject: headers.subject || '', text: bodyParts.join('\n\n').replace(/\r\n/g, '\n').trim(), headers };
++}
++
++async function lookupTxt(host, lookup = (name) => dns.resolveTxt(name)) {
++ try { return (await lookup(host)).map((parts) => Array.isArray(parts) ? parts.join('') : String(parts)); }
++ catch { return []; }
++}
++
++async function hasDkimRecords(ctx, domain) {
++ const hosts = Array.isArray(ctx.settings.dkim_hosts) ? ctx.settings.dkim_hosts : [];
++ if (!hosts.length) return false;
++ for (const entry of hosts) {
++ const host = typeof entry === 'string' ? entry.replace('{domain}', domain) : `${entry.name || ''}.${domain}`;
++ const actual = await lookupTxt(host, ctx.lookupTxt);
++ if (!actual.length) return false;
++ if (entry && typeof entry === 'object' && entry.value && !actual.some((value) => value.replace(/\s+/g, '').includes(String(entry.value).replace(/\s+/g, '')))) return false;
++ }
++ return true;
++}
++
++async function responseJson(response) {
++ let body = {};
++ try { body = await response.json(); } catch { /* provider may return an empty accepted response */ }
++ if (!response.ok) {
++ const status = Number(response.status) || 0;
++ const detail = String(body.message || body.error || body.errors?.[0]?.message || `Provider refused the message (${status || 'no status'}).`).slice(0, 240);
++ throw new ProviderError(detail, { status, transient: status === 408 || status === 425 || status === 429 || status >= 500, code: status >= 500 || status === 429 ? 'provider.transient' : 'provider.rejected' });
++ }
++ return body;
++}
++
++async function post(fetchImpl, url, headers, body) {
++ try {
++ const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
++ const data = await responseJson(response);
++ const messageId = response.headers && response.headers.get ? response.headers.get('x-message-id') : null;
++ return messageId ? { ...data, _messageId: messageId } : data;
++ } catch (err) {
++ if (err instanceof ProviderError) throw err;
++ throw new ProviderError('The provider connection ended before its response was confirmed.', { transient: true, uncertain: true, code: 'provider.uncertain' });
++ }
++}
++
++function hmac(key, value, encoding) { return crypto.createHmac('sha256', key).update(value, 'utf8').digest(encoding); }
++function sesAuthorization({ accessKeyId, secretAccessKey, region }, body, now = new Date()) {
++ const host = `email.${region}.amazonaws.com`;
++ const date = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
++ const day = date.slice(0, 8);
++ const hashed = crypto.createHash('sha256').update(body).digest('hex');
++ const canonical = `POST\n/v2/email/outbound-emails\n\ncontent-type:application/json\nhost:${host}\nx-amz-date:${date}\n\ncontent-type;host;x-amz-date\n${hashed}`;
++ const scope = `${day}/${region}/ses/aws4_request`;
++ const stringToSign = `AWS4-HMAC-SHA256\n${date}\n${scope}\n${crypto.createHash('sha256').update(canonical).digest('hex')}`;
++ const dateKey = hmac(`AWS4${secretAccessKey}`, day);
++ const regionKey = crypto.createHmac('sha256', dateKey).update(region).digest();
++ const serviceKey = crypto.createHmac('sha256', regionKey).update('ses').digest();
++ const signingKey = crypto.createHmac('sha256', serviceKey).update('aws4_request').digest();
++ const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex');
++ return { host, date, authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=content-type;host;x-amz-date, Signature=${signature}` };
++}
++
++function sesGetAuthorization({ accessKeyId, secretAccessKey, region }, path, now = new Date()) {
++ const host = `email.${region}.amazonaws.com`;
++ const date = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
++ const day = date.slice(0, 8);
++ const canonical = `GET\n${path}\n\nhost:${host}\nx-amz-date:${date}\n\nhost;x-amz-date\n${crypto.createHash('sha256').update('').digest('hex')}`;
++ const scope = `${day}/${region}/ses/aws4_request`;
++ const stringToSign = `AWS4-HMAC-SHA256\n${date}\n${scope}\n${crypto.createHash('sha256').update(canonical).digest('hex')}`;
++ const dateKey = hmac(`AWS4${secretAccessKey}`, day);
++ const regionKey = crypto.createHmac('sha256', dateKey).update(region).digest();
++ const serviceKey = crypto.createHmac('sha256', regionKey).update('ses').digest();
++ const signingKey = crypto.createHmac('sha256', serviceKey).update('aws4_request').digest();
++ const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex');
++ return { host, date, authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=host;x-amz-date, Signature=${signature}` };
++}
++
++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 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 };
++ },
++ async domainStatus(ctx, domain) { const c = ctx.credentials, path = `/v2/email/identities/${encodeURIComponent(domain)}`; const signed = sesGetAuthorization(c, path); const data = await getJson(ctx.fetch, `https://${signed.host}${path}`, { host: signed.host, 'x-amz-date': signed.date, authorization: signed.authorization }); return data.VerifiedForSendingStatus === true && data.DkimAttributes?.Status === 'SUCCESS'; },
++ },
++ // Cloudflare Email Sending REST API: https://developers.cloudflare.com/email-service/api/send-emails/rest-api/ . DNS records: https://developers.cloudflare.com/email-service/configuration/domains/
++ cloudflare: {
++ async send(ctx) { const s = ctx.settings; const data = await post(ctx.fetch, `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(s.account_id)}/email/sending/send`, { authorization: `Bearer ${ctx.credentials}` }, { from: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text: ctx.message.text }); if (data.success !== true) throw new ProviderError('Cloudflare rejected the message.', { status: 400 }); return { messageId: data.result?.message_id || null }; },
++ async domainStatus(ctx, domain) { const mxLookup = ctx.lookupMx || ((name) => dns.resolveMx(name)); const [spf, dkim, mx] = await Promise.all([lookupTxt(`cf-bounce.${domain}`, ctx.lookupTxt), lookupTxt(`cf-bounce._domainkey.${domain}`, ctx.lookupTxt), mxLookup(`cf-bounce.${domain}`).catch(() => [])]); return spf.some((v) => /include:_spf\.mx\.cloudflare\.net/i.test(v)) && dkim.some((v) => /v=DKIM1/i.test(v)) && mx.length > 0; },
++ },
++ // 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 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: {
++ async send(ctx) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await post(ctx.fetch, 'https://api.mailjet.com/v3.1/send', { authorization: `Basic ${auth}` }, { Messages: [{ From: { Email: ctx.message.from }, To: [{ Email: ctx.message.to }], Subject: ctx.message.subject, TextPart: ctx.message.text }] }); const message = data.Messages?.[0]; if (message?.Status !== 'success') throw new ProviderError('Mailjet did not accept the message.', { status: 400 }); return { messageId: message.To?.[0]?.MessageID || null }; },
++ async domainStatus(ctx, domain) { const c = ctx.credentials; const auth = Buffer.from(`${c.apiKey}:${c.secretKey}`).toString('base64'); const data = await getJson(ctx.fetch, `https://api.mailjet.com/v3/REST/dns/${encodeURIComponent(domain)}`, { authorization: `Basic ${auth}` }); const dnsEntry = (data.Data || [])[0] || {}; return /^(ok|valid|verified)$/i.test(String(dnsEntry.DKIMStatus || dnsEntry.DkimStatus || '')) && /^(ok|valid|verified)$/i.test(String(dnsEntry.SPFStatus || dnsEntry.SpfStatus || '')); },
++ },
++ // MailerSend Email API: https://developers.mailersend.com/api/v1/email
++ mailersend: {
++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.mailersend.com/v1/email', { authorization: `Bearer ${ctx.credentials}` }, { from: { email: ctx.message.from }, to: [{ email: ctx.message.to }], subject: ctx.message.subject, text: ctx.message.text }); return { messageId: data.message_id || data._messageId || null }; },
++ async domainStatus(ctx, domain) { const data = await getJson(ctx.fetch, 'https://api.mailersend.com/v1/domains', { authorization: `Bearer ${ctx.credentials}` }); return (data.data || []).some((d) => d.name === domain && d.is_verified === true); },
++ },
++ // 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 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: {
++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/email/send', { 'X-Smtp2go-Api-Key': ctx.credentials }, { sender: ctx.message.from, to: [ctx.message.to], subject: ctx.message.subject, text_body: ctx.message.text }); if (Number(data.data?.failed || 0) > 0) throw new ProviderError('SMTP2GO rejected the message.', { status: 400 }); return { messageId: data.data?.email_id || null }; },
++ async domainStatus(ctx, domain) { const data = await post(ctx.fetch, 'https://api.smtp2go.com/v3/domain/view', { 'X-Smtp2go-Api-Key': ctx.credentials }, { domain }); return (data.data?.domains || []).some((d) => d.domain === domain && d.verified === true && d.dkim_verified === true); },
++ },
++ // Zoho ZeptoMail send API: https://www.zoho.com/zeptomail/help/api-home.html . Its domain management APIs require OAuth rather than the send-only agent key, so this send-key adapter does not claim API-verified DKIM status.
++ zeptomail: {
++ async send(ctx) { const data = await post(ctx.fetch, 'https://api.zeptomail.com/v1.1/email', { authorization: `Zoho-enczapikey ${ctx.credentials}` }, { from: { address: ctx.message.from }, to: [{ email_address: { address: ctx.message.to } }], subject: ctx.message.subject, textbody: ctx.message.text }); return { messageId: data.request_id || null }; },
++ async domainStatus(ctx, domain) { return hasDkimRecords(ctx, domain); },
++ },
++ // 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 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 function getJson(fetchImpl, url, headers) {
++ try { return await responseJson(await fetchImpl(url, { headers, signal: AbortSignal.timeout(10000) })); }
++ catch (err) { if (err instanceof ProviderError) throw err; throw new ProviderError('The provider status request failed.', { transient: true, uncertain: true }); }
++}
++
++function normalizeWebhook(adapter, payload) {
++ const root = payload || {};
++ const nested = root.data || root.event_data || root.message || root;
++ const event = [root.event, root.RecordType, root.Type, root.type, root.EventType, root.event_type, root.eventType, root.notificationType, root.notification_type, root.Event, root.Bounce?.BounceType, nested.event, nested.type, nested.RecordType, nested.event_type].filter(Boolean).join(' ').toLowerCase();
++ const recipients = Array.isArray(nested.recipients) ? nested.recipients : Array.isArray(nested.To) ? nested.To : [];
++ const firstRecipient = recipients[0];
++ const sesRecipient = adapter === 'ses' ? (root.Bounce?.bouncedRecipients?.[0]?.emailAddress || root.Complaint?.complainedRecipients?.[0]?.emailAddress || root.Delivery?.recipients?.[0]) : '';
++ 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' };
++}
++
++function adapterFor(name) {
++ if (name === 'smtp') return {
++ async send(ctx) { const result = await smtp.sendMessage(ctx.route, { from: ctx.message.from, to: ctx.message.to, message: ctx.raw }); if (!result.ok) throw new ProviderError(result.reason || 'SMTP provider refused the message.', { status: result.code || 0, transient: Number(result.code) >= 400 && Number(result.code) < 500 }); return { messageId: result.messageId || null }; },
++ async domainStatus(ctx, domain) { return hasDkimRecords(ctx, domain); },
++ parseWebhook(payload) { return normalizeWebhook('smtp', payload); },
++ };
++ const adapter = apiAdapters[name];
++ if (!adapter) throw new ProviderError('The configured mail provider is not supported.', { code: 'provider.unsupported' });
++ return { ...adapter, parseWebhook: (payload) => normalizeWebhook(name, payload) };
++}
++
++async function send({ route, from, to, message, fetchImpl = globalThis.fetch }) {
++ const provider = adapterFor(route.adapter || 'smtp');
++ const credentials = route.password;
++ try {
++ const result = await provider.send({ route, settings: route.settings || {}, credentials, message: { ...parseMessage(message), from, to }, raw: message, fetch: fetchImpl });
++ return { ok: true, code: 250, reason: null, transient: false, messageId: result.messageId || null };
++ } catch (err) {
++ return { ok: false, code: err.status || 0, reason: err.message, transient: Boolean(err.transient), uncertain: Boolean(err.uncertain), errorCode: err.code || 'provider.error' };
++ }
++}
++
++async function domainStatus({ route, domain, fetchImpl = globalThis.fetch, lookupTxt: lookupTxtImpl, lookupMx: lookupMxImpl }) {
++ try { return await adapterFor(route.adapter || 'smtp').domainStatus({ settings: route.settings || {}, credentials: route.password, fetch: fetchImpl, lookupTxt: lookupTxtImpl, lookupMx: lookupMxImpl }, String(domain).toLowerCase()); }
++ catch { return false; }
++}
++
++module.exports = { ProviderError, parseMessage, lookupTxt, adapterFor, normalizeWebhook, send, domainStatus, sesAuthorization, sesGetAuthorization, apiAdapters };
ddiff --git a/server/domain/provider-domains.js b/server/domain/provider-domains.js
nnew file mode 100644
iindex 0000000..e30c249
---- /dev/null
++++ b/server/domain/provider-domains.js
@@@ -0,0 +1,75 @@
++'use strict';
++
++const dns = require('node:dns/promises');
++const { sql } = require('openvibe-sdk/db');
++
++function validDomain(value) {
++ const domain = String(value || '').trim().toLowerCase().replace(/\.$/, '');
++ if (domain.length > 253 || !domain.includes('.') || !domain.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(part))) throw new Error('Enter a valid mail domain.');
++ return domain;
++}
++
++function validateRecords(records) {
++ if (!Array.isArray(records) || records.length > 20) throw new Error('Add no more than 20 DNS records.');
++ return records.map((record) => {
++ const type = String(record.type || '').toUpperCase();
++ const name = String(record.name || '').trim();
++ const value = String(record.value || '').trim();
++ const priority = record.priority === undefined || record.priority === '' ? null : Number(record.priority);
++ if (!['TXT','CNAME','MX'].includes(type) || !name || name.length > 253 || !value || value.length > 2048 || (priority !== null && (!Number.isInteger(priority) || priority < 0 || priority > 65535))) throw new Error('Each DNS record needs a valid TXT, CNAME, or MX name and value.');
++ return { type, name, value, priority };
++ });
++}
++
++async function save({ db, providerId, input }) {
++ const domain = validDomain(input.domain);
++ const records = validateRecords(input.dkim_records || []);
++ const returnPath = input.return_path ? validDomain(input.return_path) : null;
++ const include = input.spf_include ? String(input.spf_include).trim().toLowerCase() : null;
++ if (include && !/^[a-z0-9.-]+$/.test(include)) throw new Error('The SPF include must be a DNS hostname.');
++ if (!await db.maybe(sql`SELECT id FROM mail_outbound_providers WHERE id=${providerId}`)) throw new Error('Select a configured provider.');
++ await db.exec(sql`INSERT INTO mail_outbound_domains(provider_id,domain,dkim_records,return_path,spf_include)
++ VALUES (${providerId},${domain},${JSON.stringify(records)}::jsonb,${returnPath},${include})
++ ON CONFLICT(provider_id,domain) DO UPDATE SET dkim_records=excluded.dkim_records,return_path=excluded.return_path,spf_include=excluded.spf_include,updated_at=now()`);
++ return domain;
++}
++
++async function lookupCount(domain, resolveTxt = (host) => dns.resolveTxt(host), includes = []) {
++ let count = 0;
++ let uncertain = false;
++ let visits = 0;
++ async function visit(host, ancestors = new Set()) {
++ if (ancestors.has(host)) { uncertain = true; return; }
++ if (++visits > 100) { uncertain = true; return; }
++ let records;
++ try { records = await resolveTxt(host); } catch { uncertain = true; return; }
++ const spf = records.map((parts) => Array.isArray(parts) ? parts.join('') : String(parts)).find((record) => /^v=spf1(?:\s|$)/i.test(record));
++ if (!spf) { uncertain = true; return; }
++ const nestedAncestors = new Set([...ancestors, host]);
++ for (const mechanism of spf.split(/\s+/).slice(1)) {
++ const bare = mechanism.replace(/^[+?~-]/, '');
++ const include = /^include:(.+)$/i.exec(bare);
++ if (include) { count += 1; await visit(include[1], nestedAncestors); }
++ else if (/^(?:a(?:$|[:/])|mx(?:$|[:/])|ptr(?:$|[:/])|exists:|redirect=)/i.test(bare)) {
++ count += 1;
++ if (/^redirect=/i.test(bare)) await visit(bare.slice(9), nestedAncestors);
++ }
++ }
++ }
++ count = includes.length;
++ for (const include of includes) await visit(include);
++ return { count, uncertain };
++}
++
++async function list({ db, domain: inputDomain, resolveTxt }) {
++ const domain = validDomain(inputDomain);
++ const rows = await db.many(sql`SELECT d.provider_id,p.name,p.adapter,d.domain,d.dkim_records,d.return_path,d.spf_include
++ FROM mail_outbound_domains d JOIN mail_outbound_providers p ON p.id=d.provider_id
++ WHERE d.domain=${domain} AND p.enabled=true ORDER BY p.priority,p.id`);
++ const includes = [...new Set(rows.map((row) => row.spf_include).filter(Boolean))];
++ const spf = ['v=spf1', ...includes.map((item) => `include:${item}`), '-all'].join(' ');
++ const count = await lookupCount(domain, resolveTxt, includes);
++ return { domain, records: rows, spf, lookup_count: count.count, lookup_count_uncertain: count.uncertain, warn_before_limit: count.uncertain || count.count >= 8, lookup_limit: 10 };
++}
++
++module.exports = { validDomain, validateRecords, save, list, lookupCount };
ddiff --git a/server/domain/provider-webhooks.js b/server/domain/provider-webhooks.js
nnew file mode 100644
iindex 0000000..56b81bf
---- /dev/null
++++ b/server/domain/provider-webhooks.js
@@@ -0,0 +1,152 @@
++'use strict';
++
++const crypto = require('node:crypto');
++const { sql } = require('openvibe-sdk/db');
++const adapters = require('./provider-adapters');
++const providers = require('./outbound-providers');
++
++const equal = (left, right) => {
++ const a = Buffer.from(String(left || ''));
++ const b = Buffer.from(String(right || ''));
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++};
++
++function verifyHmac(secret, raw, supplied) {
++ const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
++ return (String(supplied || '').split(/\s+/).some((part) => equal(part.replace(/^sha256=/i, ''), expected)) ||
++ equal(String(supplied || '').replace(/^sha256=/i, ''), crypto.createHmac('sha256', secret).update(raw).digest('base64')));
++}
++
++function verifyResend(secret, raw, headers, now = Date.now()) {
++ const id = headers['svix-id'], timestamp = headers['svix-timestamp'], signatures = headers['svix-signature'];
++ const seconds = Number(timestamp);
++ if (!id || !Number.isFinite(seconds) || Math.abs(now / 1000 - seconds) > 300) return false;
++ const key = String(secret).replace(/^whsec_/, '');
++ let decoded;
++ try { decoded = Buffer.from(key, 'base64'); } catch { return false; }
++ const signed = `${id}.${timestamp}.${raw}`;
++ const expected = crypto.createHmac('sha256', decoded).update(signed).digest('base64');
++ return String(signatures || '').split(/\s+/).some((item) => {
++ const [version, value] = item.split(',', 2);
++ return version === 'v1' && equal(value, expected);
++ });
++}
++
++function snsCanonical(message) {
++ const order = message.Type === 'Notification'
++ ? ['Message','MessageId','Subject','Timestamp','TopicArn','Type']
++ : ['Message','MessageId','SubscribeURL','Timestamp','Token','TopicArn','Type'];
++ return order.filter((key) => message[key] !== undefined).map((key) => `${key}\n${message[key]}\n`).join('');
++}
++
++async function verifySes(secret, payload, fetchImpl, expectedTopicArn) {
++ // Validate SNS messages with the signed TopicArn and Amazon SNS certificate URL:
++ // https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
++ if (!expectedTopicArn || payload.TopicArn !== expectedTopicArn) return false;
++ const url = String(payload.SigningCertURL || '');
++ let parsed;
++ try { parsed = new URL(url); } catch { return false; }
++ if (parsed.protocol !== 'https:' || !/^sns(?:\.[a-z0-9-]+)?\.amazonaws\.com$/i.test(parsed.hostname) || !/^\/SimpleNotificationService-[a-f0-9-]+\.pem$/i.test(parsed.pathname)) return false;
++ const response = await fetchImpl(url, { signal: AbortSignal.timeout(5000) });
++ if (!response.ok) return false;
++ const cert = await response.text();
++ const signature = Buffer.from(String(payload.Signature || ''), 'base64');
++ const algorithm = payload.SignatureVersion === '2' ? 'RSA-SHA256' : payload.SignatureVersion === '1' ? 'RSA-SHA1' : null;
++ if (!algorithm) return false;
++ return crypto.verify(algorithm, Buffer.from(snsCanonical(payload)), cert, signature);
++}
++
++/** A verified SNS SubscriptionConfirmation is confirmed by fetching its SubscribeURL, only when that URL is Amazon's SNS
++ * endpoint confirming this very topic (https://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.confirm.html). */
++function snsConfirmUrl(payload) {
++ if (!payload || payload.Type !== 'SubscriptionConfirmation') return null;
++ let url;
++ try { url = new URL(String(payload.SubscribeURL || '')); } catch { return null; }
++ if (url.protocol !== 'https:' || !/^sns\.[a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) || url.port) return null;
++ if (url.searchParams.get('Action') !== 'ConfirmSubscription' || url.searchParams.get('TopicArn') !== payload.TopicArn) return null;
++ return url.toString();
++}
++
++async function confirmSnsSubscription(payload, fetchImpl = globalThis.fetch) {
++ const url = snsConfirmUrl(payload);
++ if (!url) return false;
++ const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(5000) });
++ return Boolean(response && response.ok);
++}
++
++async function verify({ adapter, secret, payload, rawBody, headers = {}, fetchImpl = globalThis.fetch, now = Date.now(), expectedTopicArn }) {
++ const raw = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''));
++ if (adapter === 'ses') return verifySes(secret, payload, fetchImpl, expectedTopicArn);
++ if (!secret) return false;
++ // Resend uses Svix's raw-body signature and timestamp headers: https://resend.com/docs/webhooks/signature-verification
++ if (adapter === 'resend') return verifyResend(secret, raw, headers, now);
++ // Postmark Basic Auth: https://postmarkapp.com/developer/webhooks/webhooks-overview
++ // Mailjet recommends HTTPS plus URL Basic Auth: https://dev.mailjet.com/docs/email-api/webhooks/webhooks-overview/
++ if (adapter === 'postmark' || adapter === 'mailjet') {
++ const auth = String(headers.authorization || '');
++ const expected = `Basic ${Buffer.from(String(secret)).toString('base64')}`;
++ return equal(auth, expected) || equal(headers['x-postmark-webhook-token'], secret);
++ }
++ // MailerSend v2 HMAC: https://developers.mailersend.com/api/v1/account/webhooks#security
++ if (adapter === 'mailersend') return verifyHmac(secret, raw, headers.signature);
++ const auth = String(headers.authorization || '');
++ if (auth.startsWith('Basic ') && equal(auth, `Basic ${Buffer.from(String(secret)).toString('base64')}`)) return true;
++ // Brevo's transactional webhook docs describe unsigned JSON notifications, so require owner-configured shared
++ // authentication for it and providers without a documented payload signature. https://developers.brevo.com/docs/transactional-webhooks
++ return equal(headers['x-mail-webhook-secret'], secret);
++}
++
++function eventId(payload, normalized) {
++ return String(payload.event_id || payload.id || payload.MessageID || payload.MessageId || payload.message_id || payload.sg_event_id ||
++ crypto.createHash('sha256').update(JSON.stringify([normalized.adapter, normalized.event, normalized.recipient, payload.timestamp || payload.date || ''])).digest('hex')).slice(0, 250);
++}
++
++async function ingest({ db, provider, payload, now = new Date() }) {
++ const normalized = adapters.adapterFor(provider.adapter).parseWebhook(payload);
++ if (!normalized.recipient || normalized.kind === 'unknown') return { accepted: true, ignored: true };
++ const address = normalized.recipient.toLowerCase();
++ const recipientDomain = address.split('@').pop();
++ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address) || recipientDomain.length > 253) return { accepted: true, ignored: true };
++ const id = eventId(payload, normalized);
++ return db.tx(async (t) => {
++ 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.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`);
++ }
++ const column = normalized.kind === 'delivered' ? 'delivered' : normalized.kind === 'complaint' ? 'complaints' : normalized.kind === 'hard_bounce' ? 'bounced' : null;
++ if (column === 'delivered') await t.exec(sql`INSERT INTO mail_outbound_deliverability(provider_id,recipient_domain,delivered) VALUES (${provider.id},${recipientDomain},1)
++ ON CONFLICT(provider_id,recipient_domain) DO UPDATE SET delivered=mail_outbound_deliverability.delivered+1,updated_at=now()`);
++ else if (column === 'bounced') await t.exec(sql`INSERT INTO mail_outbound_deliverability(provider_id,recipient_domain,bounced) VALUES (${provider.id},${recipientDomain},1)
++ ON CONFLICT(provider_id,recipient_domain) DO UPDATE SET bounced=mail_outbound_deliverability.bounced+1,updated_at=now()`);
++ else if (column === 'complaints') await t.exec(sql`INSERT INTO mail_outbound_deliverability(provider_id,recipient_domain,complaints) VALUES (${provider.id},${recipientDomain},1)
++ ON CONFLICT(provider_id,recipient_domain) DO UPDATE SET complaints=mail_outbound_deliverability.complaints+1,updated_at=now()`);
++ return { accepted: true, kind: normalized.kind };
++ });
++}
++
++async function handle({ db, config, providerId, payload, rawBody, headers, fetchImpl, now }) {
++ const row = (await providers.list(db)).find((item) => item.id === providerId);
++ if (!row || !row.enabled) return { status: 404, body: { error: 'not_found' } };
++ const key = providers.keyFrom(config);
++ const secret = providers.open({ ...row, credential_ciphertext: row.webhook_secret_ciphertext, credential_nonce: row.webhook_secret_nonce, credential_tag: row.webhook_secret_tag }, key);
++ let parsed = payload;
++ // SNS wraps the actual SES event in an escaped Message string.
++ if (row.adapter === 'ses' && parsed.Type === 'Notification' && typeof parsed.Message === 'string') {
++ try { parsed = JSON.parse(parsed.Message); } catch { return { status: 400, body: { error: 'invalid_payload' } }; }
++ }
++ const valid = await verify({ adapter: row.adapter, secret, payload, rawBody, headers, fetchImpl, now: now ? now.getTime() : Date.now(), expectedTopicArn: row.settings && row.settings.sns_topic_arn });
++ if (!valid) return { status: 401, body: { error: 'invalid_signature' } };
++ if (row.adapter === 'ses' && payload.Type === 'SubscriptionConfirmation') {
++ const confirmed = await confirmSnsSubscription(payload, fetchImpl).catch(() => false);
++ return { status: confirmed ? 200 : 502, body: { confirmed } };
++ }
++ if (row.adapter === 'ses' && payload.Type === 'UnsubscribeConfirmation') return { status: 200, body: { accepted: true, ignored: true } };
++ const events = Array.isArray(parsed) ? parsed : [parsed];
++ const results = await Promise.all(events.map((event) => ingest({ db, provider: row, payload: event, now })));
++ return { status: 200, body: { accepted: true, events: results.length, duplicates: results.filter((result) => result.duplicate).length } };
++}
++
++module.exports = { verify, verifyResend, verifySes, snsCanonical, snsConfirmUrl, confirmSnsSubscription, ingest, handle, verifyHmac };
ddiff --git a/server/domain/sending.js b/server/domain/sending.js
iindex 0302b40..1a894dd 100644
---- a/server/domain/sending.js
++++ b/server/domain/sending.js
@@@ -19,6 +19,7 @@
*/
const { sql } = require('openvibe-sdk/db');
const reply = require('./agent-reply');
++const providers = require('./outbound-providers');
/** The words a person reads when a reply cannot go outside OpenVibe yet. */
const OUTBOUND_CLOSED = 'OpenVibe Mail cannot send outside OpenVibe yet: the reply waits here.';
@@@ -38,6 +39,7 @@ function routeFor(recipient, config) {
const outbound = config.outbound;
if (!outbound || !outbound.enabled) return null;
const relay = config.relay;
++ if ((!relay || !relay.host) && config.outbound.providerRouting !== false) return { kind: 'relay', providerManaged: true, timeoutMs: reply.TIMEOUT_MS };
if (!relay || !relay.host) return null;
return {
kind: 'relay', host: relay.host, port: relay.port, secure: relay.secure,
@@@ -68,10 +70,15 @@ async function sendReply({ db, config, inbox, owner, to, body, subject = null, i
const rfcMessageId = messageId || reply.newMessageId(domain);
const built = reply.buildReply({ from, to: recipient, subject, body, messageId: rfcMessageId, inReplyTo, references, at: now(), agentName });
-- const route = routeFor(recipient, config);
++ let route = routeFor(recipient, config);
++ const managed = !isLocal(recipient, config) && config.outbound && config.outbound.enabled ? await providers.list(db) : [];
++ if (managed.length) route = { kind: 'relay', providerManaged: true };
let outcome;
if (!route) {
outcome = { ok: false, code: 0, reason: OUTBOUND_CLOSED };
++ } else if (route.providerManaged) {
++ outcome = await providers.sendWithFailover({ db, config, from, to: recipient, message: built, kind: 'person', smtp: mailer, now: new Date(now()) });
++ route = outcome.route || null;
} else {
outcome = await mailer.sendMessage(route, { from, to: recipient, message: built });
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex b2ea966..091112e 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -35,11 +35,14 @@ const burners = require('../domain/burners');
const agentInboxes = require('../domain/agent-inboxes');
const messages = require('../domain/messages');
const answering = require('../domain/agent-answering');
++const outboundProviders = require('../domain/outbound-providers');
++const providerWebhooks = require('../domain/provider-webhooks');
++const providerDomains = require('../domain/provider-domains');
/** Whether this inbox answers mail (track HO6): off unless the person turned it on. */
const agentInboxesAnswering = (db, owner, inbox) => answering.getAnswering({ db, owner, inbox });
--function createApi({ db, config, stalwart, events, principal, log = console, smtp = null, imap = null }) {
++function createApi({ db, config, stalwart, events, principal, log = console, smtp = null, fetchImpl = globalThis.fetch, imap = null }) {
const router = express.Router();
const requirePerson = principal.requirePerson;
@@@ -62,6 +65,52 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
return http.sendProblem(res, status, code, { detail, ctx: req.ov });
}
++ // Provider keys and spend limits belong to the Network owner. Hide this surface from every other rank and from
++ // app tokens; a provider credential is only accepted over the service's encrypted-at-rest vault.
++ const ownerOnly = (req, res, next) => {
++ if (!req.principal || req.principal.kind !== 'user' || req.principal.role !== 'owner') {
++ return fail(req, res, 404, 'route.not_found', 'No such API route.');
++ }
++ return next();
++ };
++ router.post('/api/v1/mail/provider-webhooks/:providerId', wrap(async (req, res) => {
++ try {
++ const result = await providerWebhooks.handle({ db, config, providerId: req.params.providerId, payload: req.body || {}, rawBody: req.rawBody, headers: req.headers, fetchImpl });
++ return res.status(result.status).json(result.body);
++ } catch (err) {
++ log.warn({ providerId: req.params.providerId }, 'mail provider webhook rejected');
++ return fail(req, res, 400, 'mail.webhook.invalid', 'The provider webhook could not be verified or processed.');
++ }
++ }));
++ router.get('/api/v1/admin/mail/providers', ownerOnly, wrap(async (_req, res) => {
++ const rows = await outboundProviders.list(db);
++ return res.json({ outbound_enabled: Boolean(config.outbound.enabled), providers: rows.map(outboundProviders.publicProvider) });
++ }));
++ router.put('/api/v1/admin/mail/providers/:id', ownerOnly, wrap(async (req, res) => {
++ const input = { ...(req.body || {}), id: req.params.id };
++ try {
++ await outboundProviders.save({ db, config, input });
++ const row = (await outboundProviders.list(db)).find((item) => item.id === req.params.id);
++ return res.json({ provider: outboundProviders.publicProvider(row) });
++ } catch (err) {
++ return fail(req, res, 422, 'mail.provider.invalid', err.message);
++ }
++ }));
++ router.delete('/api/v1/admin/mail/providers/:id', ownerOnly, wrap(async (req, res) => {
++ await outboundProviders.remove(db, req.params.id);
++ return res.status(204).end();
++ }));
++ router.get('/api/v1/admin/mail/domains', ownerOnly, wrap(async (req, res) => {
++ try { return res.json(await providerDomains.list({ db, domain: req.query.domain })); }
++ catch (err) { return fail(req, res, 422, 'mail.domain.invalid', err.message); }
++ }));
++ router.put('/api/v1/admin/mail/domains/:providerId', ownerOnly, wrap(async (req, res) => {
++ try {
++ const domain = await providerDomains.save({ db, providerId: req.params.providerId, input: req.body || {} });
++ return res.json(await providerDomains.list({ db, domain }));
++ } catch (err) { return fail(req, res, 422, 'mail.domain.invalid', err.message); }
++ }));
++
// ---- the person's front door ---------------------------------------------------------------
/**
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bf3538c..2d8b452 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -147,6 +147,18 @@ function inboxPage(config) {
});
}
++function providerAdminPage(config) {
++ const base = String(config.baseUrl || 'https://openvibe.email').replace(/\/+$/, '');
++ const nav = { service: SERVICE, apiBase: NETWORK_URL, links: [{ label: 'Home', href: '/' }, { label: 'Inbox', href: '/inbox' }, { label: 'Mail providers', href: '/admin/mail/providers', active: true }], history: { type: 'page', title: 'Mail providers' }, silentLogin: `${base}/auth/login?silent=1&next={url}`, sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' };
++ return shell.page({
++ name: SITE_NAME, lang: 'en', title: `Mail providers · ${SITE_NAME}`, siteName: SITE_NAME,
++ description: 'Owner controls for outbound mail providers and monthly budgets.', canonical: `${base}/admin/mail/providers`,
++ robots: 'noindex, nofollow', home: '/', navLinks: nav.links, navbar: nav,
++ head: `<meta name="referrer" content="no-referrer"><link rel="stylesheet" href="${ovServe.url('kit.css')}"><link rel="stylesheet" href="${asset('css/mail.css')}"><script src="${asset('js/mail-providers.js')}" defer></script>`,
++ body: `<div id="navbar-mount"></div><main id="main" data-mail-provider-admin><h1>Outbound mail providers</h1><p>External delivery stays closed unless MAIL_OUTBOUND is enabled by the service operator. Every paid provider needs a monthly cap.</p><p data-status role="status">Loading provider settings…</p><section aria-labelledby="configured-heading"><h2 id="configured-heading">Configured providers</h2><p>Webhook URL appears on each provider card. Use Resend/Svix or MailerSend HMAC verification, Postmark/Mailjet Basic Auth, or the owner-configured shared webhook secret for providers without a documented signature. SES also needs its configured SNS topic ARN.</p><div data-provider-list></div></section><section aria-labelledby="dns-heading"><h2 id="dns-heading">Mail domains and DNS</h2><form data-domain-form><label>Domain <input name="domain" required maxlength="253" placeholder="example.com"></label><label>Provider <select name="provider_id" data-domain-providers required></select></label><label>DKIM and verification DNS records (JSON) <textarea name="dkim_records" rows="5">[]</textarea></label><label>Return-path hostname <input name="return_path" maxlength="253" placeholder="bounce.example.com"></label><label>SPF include hostname <input name="spf_include" maxlength="253" placeholder="spf.provider.example"></label><button type="submit">Save domain records</button></form><div data-domain-status></div></section><section aria-labelledby="add-heading"><h2 id="add-heading">Add mail provider</h2><form data-provider-form><label>Provider <select name="adapter"><option value="smtp">Generic SMTP</option><option value="ses">Amazon SES v2</option><option value="cloudflare">Cloudflare Email Sending</option><option value="brevo">Brevo</option><option value="mailjet">Mailjet</option><option value="mailersend">MailerSend</option><option value="resend">Resend</option><option value="smtp2go">SMTP2GO</option><option value="zeptomail">Zoho ZeptoMail</option><option value="postmark">Postmark</option></select></label><label>Name <input name="name" required maxlength="100"></label><label>SMTP host (generic SMTP only) <input name="host" maxlength="253"></label><label>Port <input name="port" type="number" min="1" max="65535" value="587" required></label><label>Username <input name="username" maxlength="200"></label><label>Credential (API token, or JSON for SES/Mailjet) <input name="password" type="password" autocomplete="new-password"></label><label>Webhook secret or Basic Auth credential (except Amazon SNS) <input name="webhook_secret" type="password" autocomplete="new-password"></label><label>Provider settings JSON, such as {"account_id":"…"}, {"region":"us-east-1","sns_topic_arn":"arn:aws:sns:…"} <input name="settings_json" value="{}"></label><label>Free messages each day (0 = no separate daily cap) <input name="free_daily" type="number" min="0" value="0"></label><label>Free messages each month <input name="free_monthly" type="number" min="0" value="0"></label><label>Price per 1,000 after free allowance (USD) <input name="price_per_thousand" type="number" min="0" step="0.000001" value="0"></label><label>Monthly budget cap (USD) <input name="monthly_budget" type="number" min="0" step="0.01" value="0" required></label><label>Rate limit per minute <input name="rate_limit_per_minute" type="number" min="1" value="60" required></label><fieldset><legend>Enabled message kinds</legend><label><input name="kind" type="checkbox" value="system" checked> System mail</label><label><input name="kind" type="checkbox" value="person" checked> Person mail</label></fieldset><label>Priority <input name="priority" type="number" value="100"></label><label><input name="secure" type="checkbox"> TLS from connection (SMTP)</label><label><input name="starttls" type="checkbox" checked> Use STARTTLS when offered (SMTP)</label><label><input name="enabled" type="checkbox"> Enable provider</label><button type="submit">Save provider</button></form></section><p data-error role="alert" hidden></p></main><script>window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};</script>`,
++ });
++}
++
/** Mount the page, its files and Shared's. Before the API's 404 handler. */
function mountPages(app, { config }) {
app.use('/shared', ovServe.handler());
@@@ -167,6 +179,12 @@ function mountPages(app, { config }) {
res.set('Cache-Control', 'private, no-store');
res.type('html').send(inbox);
});
++ app.get('/admin/mail/providers', (req, res) => {
++ if (!req.user || req.user.role !== 'owner') return res.status(404).type('text/plain').send('Not found');
++ res.set('Content-Security-Policy', INBOX_CSP);
++ res.set('Cache-Control', 'private, no-store');
++ res.type('html').send(providerAdminPage(config));
++ });
let html = null;
app.get('/', (req, res) => {
html = html || homePage(config);
@@@ -176,4 +194,4 @@ function mountPages(app, { config }) {
});
}
--module.exports = { mountPages, homePage, inboxPage, siteIdentity, CSP, INBOX_CSP, asset };
++module.exports = { mountPages, homePage, inboxPage, providerAdminPage, siteIdentity, CSP, INBOX_CSP, asset };
ddiff --git a/test/page.test.js b/test/page.test.js
iindex 872fc88..a791171 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -51,6 +51,11 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
assert.strictEqual(inboxJs.status, 200);
assert.match(inboxJs.text, /Connected mailboxes/, 'Inbox AI shows the connected mailboxes');
assert.match(inboxJs.text, /imap-mailboxes/);
++ const providerJs = await s.get(asset('js/mail-providers.js'));
++ assert.strictEqual(providerJs.status, 200);
++ for (const marker of ['free_daily', 'price_per_thousand', 'monthly_budget', 'rate_limit_per_minute', 'enabled_kinds', 'lookup_count', 'bounce_rate', 'spend_this_month']) {
++ assert.ok(providerJs.text.includes(marker), marker);
++ }
const navbar = await s.get('/shared/navbar.js');
assert.strictEqual(navbar.status, 200);
const missing = await s.get('/js/nope.js');
ddiff --git a/test/provider-adapters.test.js b/test/provider-adapters.test.js
nnew file mode 100644
iindex 0000000..013699d
---- /dev/null
++++ b/test/provider-adapters.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const { send, domainStatus, normalizeWebhook, sesAuthorization } = require('../server/domain/provider-adapters');
++
++const message = 'From: [email protected]\r\nTo: [email protected]\r\nSubject: Hello\r\nMessage-ID: <[email protected]>\r\n\r\nPlain text body';
++const response = (body, { status = 200, headers = {} } = {}) => ({ ok: status >= 200 && status < 300, status, headers: { get: (name) => headers[name.toLowerCase()] || null }, json: async () => body });
++
++const cases = [
++ ['ses', { adapter: 'ses', password: { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'unit-test-secret', region: 'us-east-1' }, settings: {} }, 'https://email.us-east-1.amazonaws.com/v2/email/outbound-emails', { MessageId: 'ses-id' }],
++ ['cloudflare', { adapter: 'cloudflare', password: 'cf-token', settings: { account_id: 'account-123' } }, 'https://api.cloudflare.com/client/v4/accounts/account-123/email/sending/send', { success: true, result: { message_id: 'cf-id' } }],
++ ['brevo', { adapter: 'brevo', password: 'brevo-token', settings: {} }, 'https://api.brevo.com/v3/smtp/email', { messageId: 'brevo-id' }],
++ ['mailjet', { adapter: 'mailjet', password: { apiKey: 'public', secretKey: 'private' }, settings: {} }, 'https://api.mailjet.com/v3.1/send', { Messages: [{ Status: 'success', To: [{ MessageID: 4 }] }] }],
++ ['mailersend', { adapter: 'mailersend', password: 'ms-token', settings: {} }, 'https://api.mailersend.com/v1/email', { message_id: 'ms-id' }],
++ ['resend', { adapter: 'resend', password: 're-token', settings: {} }, 'https://api.resend.com/emails', { id: 're-id' }],
++ ['smtp2go', { adapter: 'smtp2go', password: 's2g-token', settings: {} }, 'https://api.smtp2go.com/v3/email/send', { data: { succeeded: 1, failed: 0, email_id: 's2g-id' } }],
++ ['zeptomail', { adapter: 'zeptomail', password: 'zepto-token', settings: {} }, 'https://api.zeptomail.com/v1.1/email', { request_id: 'zepto-id' }],
++ ['postmark', { adapter: 'postmark', password: 'postmark-token', settings: {} }, 'https://api.postmarkapp.com/email', { ErrorCode: 0, MessageID: 'postmark-id' }],
++];
++
++test('provider adapters use the documented endpoints, credentials and body fields', async () => {
++ for (const [adapter, route, expectedUrl, reply] of cases) {
++ let request;
++ const result = await send({ route, from: '[email protected]', to: '[email protected]', message, fetchImpl: async (url, init) => { request = { url, init }; return response(reply, { headers: { 'x-message-id': 'ms-header' } }); } });
++ assert.equal(result.ok, true, adapter);
++ assert.equal(request.url, expectedUrl, adapter);
++ const body = JSON.parse(request.init.body);
++ assert.ok(body.text || body.textContent || body.TextPart || body.TextBody || body.Content || body.text_body || body.textbody || body.Messages?.[0]?.TextPart, adapter);
++ if (adapter === 'ses') assert.match(request.init.headers.authorization, /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\//);
++ if (adapter === 'cloudflare') assert.equal(request.init.headers.authorization, 'Bearer cf-token');
++ if (adapter === 'brevo') assert.equal(request.init.headers['api-key'], 'brevo-token');
++ if (adapter === 'postmark') assert.equal(request.init.headers['X-Postmark-Server-Token'], 'postmark-token');
++ }
++});
++
++test('provider error mapping separates permanent refusal, throttling and uncertain network outcomes', async () => {
++ const route = { adapter: 'resend', password: 'token', settings: {} };
++ const rejected = await send({ route, from: '[email protected]', to: '[email protected]', message, fetchImpl: async () => response({ message: 'invalid sender' }, { status: 422 }) });
++ const throttled = await send({ route, from: '[email protected]', to: '[email protected]', message, fetchImpl: async () => response({ message: 'slow down' }, { status: 429 }) });
++ const uncertain = await send({ route, from: '[email protected]', to: '[email protected]', message, fetchImpl: async () => { throw new Error('socket closed'); } });
++ assert.equal(rejected.transient, false);
++ assert.equal(throttled.transient, true);
++ assert.equal(uncertain.uncertain, true);
++ assert.equal(uncertain.transient, true);
++});
++
++test('domain status is normalized and SES signatures cover SigV4 canonical data', async () => {
++ for (const [adapter, route] of cases) {
++ let url = '';
++ if (adapter === 'cloudflare') {
++ assert.equal(await domainStatus({ route, domain: 'openvibe.email', fetchImpl: async () => { throw new Error('Cloudflare domain status uses DNS only'); },
++ lookupTxt: async (host) => [[host.includes('_domainkey') ? 'v=DKIM1; p=test' : 'v=spf1 include:_spf.mx.cloudflare.net ~all']],
++ lookupMx: async () => [{ exchange: 'route1.mx.cloudflare.net' }] }), true, adapter);
++ continue;
++ }
++ if (adapter === 'zeptomail') {
++ assert.equal(await domainStatus({ route, domain: 'openvibe.email', fetchImpl: async () => { throw new Error('send-only key cannot inspect domains'); } }), false, adapter);
++ continue;
++ }
++ const statusReply = adapter === 'ses' ? { VerifiedForSendingStatus: true, DkimAttributes: { Status: 'SUCCESS' } }
++ : adapter === 'brevo' ? { senders: [{ domain: 'openvibe.email', active: true }] }
++ : adapter === 'mailjet' ? { Data: [{ DKIMStatus: 'verified', SPFStatus: 'verified' }] }
++ : adapter === 'mailersend' ? { data: [{ name: 'openvibe.email', is_verified: true }] }
++ : adapter === 'resend' ? { data: [{ name: 'openvibe.email', status: 'verified' }] }
++ : adapter === 'smtp2go' ? { data: { domains: [{ domain: 'openvibe.email', verified: true, dkim_verified: true }] } }
++ : adapter === 'zeptomail' ? { data: [{ domain: 'openvibe.email', verified: true }] }
++ : { SenderSignatures: [{ FromEmail: '[email protected]', Confirmed: true, DKIMVerified: true }] };
++ const verified = await domainStatus({ route, domain: 'openvibe.email', fetchImpl: async (target) => { url = target; return response(statusReply); } });
++ assert.equal(verified, true, adapter);
++ assert.match(url, /^https:\/\//);
++ }
++ const signed = sesAuthorization({ accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'test-only', region: 'us-east-1' }, '{}');
++ assert.equal(signed.host, 'email.us-east-1.amazonaws.com');
++ 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',
++ });
++ assert.deepEqual(normalizeWebhook('mailersend', { type: 'activity.spam_complaint', data: { recipient: '[email protected]' } }), {
++ adapter: 'mailersend', event: 'activity.spam_complaint', recipient: '[email protected]', kind: 'complaint',
++ });
++ assert.equal(normalizeWebhook('brevo', { event: 'softBounce', email: '[email protected]' }).kind, 'soft_bounce');
++});
ddiff --git a/test/provider-router.test.js b/test/provider-router.test.js
nnew file mode 100644
iindex 0000000..4b7d5b5
---- /dev/null
++++ b/test/provider-router.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const { boot } = require('./helpers/boot');
++const providers = require('../server/domain/outbound-providers');
++
++test('outbound provider settings are hidden from non-owners and secrets are write-only', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ const user = s.network.addUser('reader');
++ const owner = s.network.addUser('owner', { role: 'owner' });
++ assert.equal((await s.get('/api/v1/admin/mail/providers', { as: user })).status, 404);
++ assert.equal((await s.get('/api/v1/admin/mail/domains?domain=example.com', { as: user })).status, 404);
++ const saved = await s.put('/api/v1/admin/mail/providers/smtp_primary', { as: owner, json: {
++ name: 'Primary SMTP', host: 'smtp.example.net', port: 587, username: 'mailer', password: 'never-return-this',
++ enabled: true, free_monthly: 1000, price_per_thousand: 0.1, monthly_budget: 5,
++ } });
++ assert.equal(saved.status, 200, saved.text);
++ assert.equal(saved.body.provider.has_credential, true);
++ assert.doesNotMatch(saved.text, /never-return-this/);
++ const row = (await s.db.many('SELECT * FROM mail_outbound_providers'))[0];
++ assert.doesNotMatch(row.credential_ciphertext, /never-return-this/);
++ assert.equal(providers.open(row, providers.keyFrom(s.config)), 'never-return-this');
++ assert.equal((await s.get('/api/v1/admin/mail/providers', { as: owner })).body.providers[0].has_credential, true);
++ assert.equal((await s.del('/api/v1/admin/mail/providers/smtp_primary', { as: owner })).status, 204);
++});
++
++test('router spreads free sends across providers and respects monthly budget', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ for (const id of ['smtp_a', 'smtp_b']) await providers.save({ db: s.db, config: s.config, input: {
++ id, name: id, host: id.replace('_', '-') + '.example.net', username: 'u', password: 'p', enabled: true,
++ free_monthly: 1, price_per_thousand: 1, monthly_budget: 0.001, priority: 10,
++ } });
++ const first = await providers.choose({ db: s.db, config: s.config });
++ const second = await providers.choose({ db: s.db, config: s.config });
++ assert.deepEqual([first.providerId, second.providerId].sort(), ['smtp_a', 'smtp_b']);
++ assert.ok(await providers.choose({ db: s.db, config: s.config }));
++ assert.ok(await providers.choose({ db: s.db, config: s.config }));
++ assert.equal(await providers.choose({ db: s.db, config: s.config }), null);
++});
++
++test('router verifies From DKIM, fails over only after transient refusal, and honors shared suppression', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ for (const id of ['smtp_a', 'smtp_b']) await providers.save({ db: s.db, config: s.config, input: {
++ id, name: id, host: id.replace('_', '-') + '.example.net', password: 'p', enabled: true,
++ free_monthly: 10, monthly_budget: 1, settings: { dkim_hosts: ['selector._domainkey.{domain}'] }, priority: id === 'smtp_a' ? 1 : 2,
++ } });
++ const attempted = [];
++ const smtp = { async sendMessage(route) { attempted.push(route.providerId); return attempted.length === 1 ? { ok: false, code: 451, transient: true, reason: 'Try again' } : { ok: true, code: 250 }; } };
++ const sent = await providers.sendWithFailover({ db: s.db, config: s.config, from: '[email protected]', to: '[email protected]', message: 'From: [email protected]\r\n\r\nhello', smtp,
++ lookupTxt: async () => [['v=DKIM1; p=example']], lookupMx: async () => [{ exchange: 'mx.example.net' }] });
++ assert.equal(sent.ok, true);
++ assert.deepEqual(attempted, ['smtp_a','smtp_b']);
++ const permanent = await providers.sendWithFailover({ db: s.db, config: s.config, from: '[email protected]', to: '[email protected]', message: 'hello', smtp: { async sendMessage(route) { attempted.push(route.providerId); return { ok: false, code: 550, reason: 'Mailbox does not exist' }; } },
++ lookupTxt: async () => [['v=DKIM1; p=example']], lookupMx: async () => [{ exchange: 'mx.example.net' }] });
++ assert.equal(permanent.ok, false);
++ assert.equal(attempted.length, 3, 'a permanent recipient failure must not be sent through another provider');
++ await s.db.exec("INSERT INTO mail_outbound_suppressions(address,reason) VALUES ('[email protected]','hard_bounce')");
++ const blocked = await providers.sendWithFailover({ db: s.db, config: s.config, from: '[email protected]', to: '[email protected]', message: 'hello', smtp });
++ assert.equal(blocked.suppressed, true);
++ assert.equal(attempted.length, 3);
++});
++
++test('transient failures open the provider circuit and allow one half-open probe after cooldown', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ await providers.save({ db: s.db, config: s.config, input: { id: 'smtp_breaker', name: 'Breaker', host: 'smtp.example.net', password: 'p', enabled: true, free_monthly: 10, monthly_budget: 1 } });
++ const start = new Date();
++ for (let index = 0; index < 3; index += 1) await providers.recordOutcome({ db: s.db, providerId: 'smtp_breaker', to: '[email protected]', ok: false, transient: true, now: start });
++ assert.equal(await providers.choose({ db: s.db, config: s.config, now: start }), null);
++ const reopened = new Date(start.getTime() + 5 * 60_000 + 1);
++ assert.ok(await providers.choose({ db: s.db, config: s.config, now: reopened }));
++ assert.equal(await providers.choose({ db: s.db, config: s.config, now: reopened }), null, 'only one half-open probe may run at once');
++});
++
++test('owner provider metrics report successful sends, remaining free volume and actual spend', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ await providers.save({ db: s.db, config: s.config, input: { id: 'smtp_metrics', name: 'Metrics', host: 'smtp.example.net', password: 'p', enabled: true, free_daily: 2, free_monthly: 1, price_per_thousand: 10, monthly_budget: 1 } });
++ for (let index = 0; index < 2; index += 1) {
++ const route = await providers.choose({ db: s.db, config: s.config });
++ const cost = route.reservation.free ? 0 : Number(route.price_per_thousand) / 1000;
++ await providers.recordOutcome({ db: s.db, providerId: route.providerId, to: '[email protected]', ok: true, spend: cost });
++ }
++ await s.db.exec("INSERT INTO mail_outbound_deliverability(provider_id,recipient_domain,delivered,bounced,complaints) VALUES ('smtp_metrics','gmail.com',9,1,0)");
++ const provider = providers.publicProvider((await providers.list(s.db))[0]);
++ assert.equal(provider.sent_today, 2);
++ assert.equal(provider.sent_this_month, 2);
++ assert.equal(provider.free_left_today, 0);
++ assert.equal(provider.free_left_this_month, 0);
++ assert.equal(provider.spend_this_month, 0.01);
++ assert.equal(provider.bounce_rate, 0.1);
++});
++
++test('paid provider cannot be enabled without a monthly budget cap', () => {
++ assert.throws(() => providers.validate({ host: 'smtp.example.net', enabled: true, price_per_thousand: 1 }), /monthly budget cap/);
++ assert.throws(() => providers.validate({ host: 'smtp.example.net', port: 70000 }), /port must be valid/);
++});
ddiff --git a/test/provider-webhooks.test.js b/test/provider-webhooks.test.js
nnew file mode 100644
iindex 0000000..2e3c3d5
---- /dev/null
++++ b/test/provider-webhooks.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++const fs = require('fs');
++
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { boot } = require('./helpers/boot');
++const providers = require('../server/domain/outbound-providers');
++const webhooks = require('../server/domain/provider-webhooks');
++const domains = require('../server/domain/provider-domains');
++
++test('Resend webhook signatures are timestamped and checked against the raw body', () => {
++ const secret = `whsec_${Buffer.from('test-hook-secret').toString('base64')}`;
++ const raw = JSON.stringify({ type: 'email.bounced' });
++ const id = 'msg-hook-1', timestamp = String(Math.floor(Date.now() / 1000));
++ const signature = crypto.createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`${id}.${timestamp}.${raw}`).digest('base64');
++ const headers = { 'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': `v1,${signature}` };
++ assert.equal(webhooks.verifyResend(secret, raw, headers), true);
++ assert.equal(webhooks.verifyResend(secret, raw + ' ', headers), false);
++ assert.equal(webhooks.verifyResend(secret, raw, { ...headers, 'svix-timestamp': '1' }), false);
++});
++
++test('SES SNS signatures require the configured topic and a trusted SNS certificate URL', async () => {
++ const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const payload = { Type: 'Notification', Message: 'example', MessageId: 'm-1', Timestamp: '2026-11-08T12:00:00Z', TopicArn: 'arn:aws:sns:us-east-1:123456789012:mail', SignatureVersion: '2', SigningCertURL: 'https://sns.us-east-1.amazonaws.com/SimpleNotificationService-abcd.pem' };
++ payload.Signature = crypto.sign('RSA-SHA256', Buffer.from(webhooks.snsCanonical(payload)), pair.privateKey).toString('base64');
++ const fetchImpl = async () => ({ ok: true, text: async () => pair.publicKey.export({ type: 'spki', format: 'pem' }) });
++ assert.equal(await webhooks.verifySes('', payload, fetchImpl, payload.TopicArn), true);
++ assert.equal(await webhooks.verifySes('', payload, fetchImpl, 'arn:aws:sns:elsewhere'), false);
++ assert.equal(await webhooks.verifySes('', { ...payload, SigningCertURL: 'https://example.net/cert.pem' }, fetchImpl, payload.TopicArn), false);
++});
++
++test('provider webhook authentication uses documented signatures or shared credentials', async () => {
++ const rawBody = Buffer.from('{"event":"bounce"}');
++ const signature = crypto.createHmac('sha256', 'mailersend-hook').update(rawBody).digest('hex');
++ assert.equal(await webhooks.verify({ adapter: 'mailersend', secret: 'mailersend-hook', payload: {}, rawBody, headers: { signature } }), true);
++ assert.equal(await webhooks.verify({ adapter: 'mailjet', secret: 'user:pass', payload: {}, rawBody, headers: { authorization: `Basic ${Buffer.from('user:pass').toString('base64')}` } }), true);
++ assert.equal(await webhooks.verify({ adapter: 'brevo', secret: 'owner-shared-secret', payload: {}, rawBody, headers: { 'x-mail-webhook-secret': 'owner-shared-secret' } }), true);
++ assert.equal(await webhooks.verify({ adapter: 'brevo', secret: 'owner-shared-secret', payload: {}, rawBody, headers: {} }), false);
++});
++
++test('verified provider webhook events are idempotent, update domain health and suppress shared hard bounces', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ const secret = `whsec_${Buffer.from('test-hook-secret').toString('base64')}`;
++ await providers.save({ db: s.db, config: s.config, input: { id: 'resend_test', adapter: 'resend', name: 'Resend test', host: 'api.resend.com', password: 'send-token', webhook_secret: secret, enabled: true, free_monthly: 100, monthly_budget: 1 } });
++ const payload = { id: 'event-1', type: 'email.bounced', data: { to: ['[email protected]'] } };
++ const rawBody = Buffer.from(JSON.stringify(payload));
++ const timestamp = String(Math.floor(Date.now() / 1000));
++ const signature = crypto.createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`evt_1.${timestamp}.${rawBody}`).digest('base64');
++ const args = { db: s.db, config: s.config, providerId: 'resend_test', payload, rawBody, headers: { 'svix-id': 'evt_1', 'svix-timestamp': timestamp, 'svix-signature': `v1,${signature}` } };
++ const stored = (await providers.list(s.db))[0];
++ assert.equal(providers.open({ ...stored, credential_ciphertext: stored.webhook_secret_ciphertext, credential_nonce: stored.webhook_secret_nonce, credential_tag: stored.webhook_secret_tag }, providers.keyFrom(s.config)), secret);
++ assert.equal(await webhooks.verify({ adapter: 'resend', secret, payload, rawBody, headers: args.headers }), true);
++ assert.equal((await webhooks.handle(args)).status, 200);
++ assert.equal(await providers.isSuppressed(s.db, '[email protected]'), true);
++ assert.equal((await webhooks.handle(args)).body.duplicates, 1);
++ const row = await s.db.maybe("SELECT bounced FROM mail_outbound_deliverability WHERE provider_id='resend_test' AND recipient_domain='gmail.com'");
++ assert.equal(Number(row.bounced), 1);
++ assert.equal((await webhooks.handle({ ...args, headers: { ...args.headers, 'svix-signature': 'v1,wrong' } })).status, 401);
++});
++
++test('SPF calculation counts provider includes and nested DNS lookups', async () => {
++ const result = await domains.lookupCount('example.com', async (host) => host === 'spf.one.test'
++ ? [['v=spf1 include:spf.nested.test mx ~all']]
++ : host === 'spf.nested.test' ? [['v=spf1 a -all']] : [['v=spf1 -all']], ['spf.one.test', 'spf.two.test']);
++ assert.equal(result.count, 5);
++ assert.equal(result.uncertain, false);
++ assert.throws(() => domains.validDomain('bad domain'), /valid mail domain/);
++});
++
++test('owner domain records are saved with return-path and generated SPF inputs', async (t) => {
++ const s = await boot({ env: { MAIL_PROVIDER_KEK: 'test-only-key', MAIL_OUTBOUND: 'on' } });
++ t.after(() => s.close());
++ await providers.save({ db: s.db, config: s.config, input: { id: 'ses_domain', adapter: 'ses', name: 'SES', host: '', password: JSON.stringify({ accessKeyId: 'key', secretAccessKey: 'secret', region: 'us-east-1' }), settings: { region: 'us-east-1' }, enabled: true, free_monthly: 100, monthly_budget: 1 } });
++ await domains.save({ db: s.db, providerId: 'ses_domain', input: { domain: 'example.com', dkim_records: [{ type: 'TXT', name: 'selector._domainkey.example.com', value: 'v=DKIM1; p=public' }], return_path: 'bounce.example.com', spf_include: 'spf.provider.test' } });
++ const data = await domains.list({ db: s.db, domain: 'example.com', resolveTxt: async (host) => host === 'spf.provider.test' ? [['v=spf1 a -all']] : [] });
++ assert.equal(data.spf, 'v=spf1 include:spf.provider.test -all');
++ assert.equal(data.lookup_count, 2);
++ assert.equal(data.lookup_count_uncertain, false);
++ assert.equal(data.records[0].return_path, 'bounce.example.com');
++ assert.equal(data.records[0].dkim_records[0].name, 'selector._domainkey.example.com');
++});
++
++test('SNS posts text/plain, and a subscription is confirmed only at Amazon SNS for the same topic', async () => {
++ const app = fs.readFileSync(require('path').join(__dirname, '..', 'server', 'app.js'), 'utf8');
++ assert.match(app, /provider-webhooks', express\.json\(\{ type: \['application\/json', 'text\/plain'\]/);
++ const topic = 'arn:aws:sns:us-east-1:123456789012:mail';
++ const good = { Type: 'SubscriptionConfirmation', TopicArn: topic, SubscribeURL: `https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=${encodeURIComponent(topic)}&Token=abc` };
++ assert.ok(webhooks.snsConfirmUrl(good));
++ assert.equal(webhooks.snsConfirmUrl({ ...good, SubscribeURL: 'https://sns.us-east-1.amazonaws.com.evil.example/?Action=ConfirmSubscription' }), null);
++ assert.equal(webhooks.snsConfirmUrl({ ...good, SubscribeURL: 'http://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=' + encodeURIComponent(topic) }), null);
++ assert.equal(webhooks.snsConfirmUrl({ ...good, SubscribeURL: good.SubscribeURL.replace('mail', 'other') }), null);
++ assert.equal(webhooks.snsConfirmUrl({ ...good, Type: 'Notification' }), null);
++ const asked = [];
++ assert.equal(await webhooks.confirmSnsSubscription(good, async (url, opts) => { asked.push([url, opts.redirect]); return { ok: true }; }), true);
++ assert.deepEqual(asked.map(([, r]) => r), ['error']);
++ assert.equal(await webhooks.confirmSnsSubscription({ ...good, SubscribeURL: 'https://127.0.0.1/?Action=ConfirmSubscription' }, async () => { throw new Error('must not fetch'); }), false);
++});