openvibers/openvibe.mail Commits
Add owner managed outbound SMTP routing (#27)
Changes
nnew file mode 100644@@@ -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)++);nnew file mode 100644@@@ -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();++}());@@@ -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.@@@ -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@@@ -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, bodif (!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, bodmessageId, 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()nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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, iconst 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 });}@@@ -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, smtreturn 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 ---------------------------------------------------------------/**@@@ -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 };@@@ -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');nnew file mode 100644@@@ -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');++});nnew file mode 100644@@@ -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/);++});nnew file mode 100644@@@ -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);++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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();
+}());
@@ -70,6 +70,8 @@ async function createApp(opts = {}) {
@@ -70,6 +70,8 @@ async function createApp(opts = {}) {
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
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' }));
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.
// 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) => {
app.use(['/api', '/auth'], (_req, res, next) => {
@@ -117,7 +119,7 @@ async function createApp(opts = {}) {
@@ -117,7 +119,7 @@ async function createApp(opts = {}) {
}));
}));
app.use('/api/v1', principal.middleware);
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
// 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.
// first-party service token; and the person's own answering switch and block list.
@@ -139,6 +139,7 @@ function load(overrides = null) {
@@ -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.
// it off Mail sends only to its own domains over loopback, and a reply to anyone else never opens a socket.
outbound: {
outbound: {
enabled: /^(1|true|on|yes)$/i.test(String(env.MAIL_OUTBOUND || '').trim()),
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
// The outbound relay, read only when outbound is switched on. Until it exists, a reply to an address outside
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const sending = require('./sending');
const sending = require('./sending');
const reply = require('./agent-reply');
const reply = require('./agent-reply');
+const providers = require('./outbound-providers');
function senderAddress(from) {
function senderAddress(from) {
const value = Array.isArray(from) ? String(from[0] || '') : String(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
@@ -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 || ''))) {
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' };
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 };
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',
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()
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
@@ -47,7 +50,9 @@ async function sendInboxDraft({ db, config, stalwart, smtp, owner, threadId, bod
messageId, inReplyTo: headers.messageId || null,
messageId, inReplyTo: headers.messageId || null,
references: [...(headers.references || []), headers.messageId].filter(Boolean), at: now() });
references: [...(headers.references || []), headers.messageId].filter(Boolean), at: now() });
const mailer = smtp || require('../stalwart/smtp');
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) {
if (!result || !result.ok) {
// Do not retry automatically; a user may explicitly click send again after seeing the failure.
// 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()
await db.exec(sql`UPDATE mail_inbox_ai_threads SET draft_state = 'draft', updated_at = now()
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -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 };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
*/
*/
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const reply = require('./agent-reply');
const reply = require('./agent-reply');
+const providers = require('./outbound-providers');
/** The words a person reads when a reply cannot go outside OpenVibe yet. */
/** 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.';
const OUTBOUND_CLOSED = 'OpenVibe Mail cannot send outside OpenVibe yet: the reply waits here.';
@@ -38,6 +39,7 @@ function routeFor(recipient, config) {
@@ -38,6 +39,7 @@ function routeFor(recipient, config) {
const outbound = config.outbound;
const outbound = config.outbound;
if (!outbound || !outbound.enabled) return null;
if (!outbound || !outbound.enabled) return null;
const relay = config.relay;
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;
if (!relay || !relay.host) return null;
return {
return {
kind: 'relay', host: relay.host, port: relay.port, secure: relay.secure,
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
@@ -68,10 +70,15 @@ async function sendReply({ db, config, inbox, owner, to, body, subject = null, i
const rfcMessageId = messageId || reply.newMessageId(domain);
const rfcMessageId = messageId || reply.newMessageId(domain);
const built = reply.buildReply({ from, to: recipient, subject, body, messageId: rfcMessageId, inReplyTo, references, at: now(), agentName });
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;
let outcome;
if (!route) {
if (!route) {
outcome = { ok: false, code: 0, reason: OUTBOUND_CLOSED };
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 {
} else {
outcome = await mailer.sendMessage(route, { from, to: recipient, message: built });
outcome = await mailer.sendMessage(route, { from, to: recipient, message: built });
}
}
@@ -35,11 +35,14 @@ const burners = require('../domain/burners');
@@ -35,11 +35,14 @@ const burners = require('../domain/burners');
const agentInboxes = require('../domain/agent-inboxes');
const agentInboxes = require('../domain/agent-inboxes');
const messages = require('../domain/messages');
const messages = require('../domain/messages');
const answering = require('../domain/agent-answering');
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. */
/** Whether this inbox answers mail (track HO6): off unless the person turned it on. */
const agentInboxesAnswering = (db, owner, inbox) => answering.getAnswering({ db, owner, inbox });
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 router = express.Router();
const requirePerson = principal.requirePerson;
const requirePerson = principal.requirePerson;
@@ -62,6 +65,52 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
@@ -62,6 +65,52 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
return http.sendProblem(res, status, code, { detail, ctx: req.ov });
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 ---------------------------------------------------------------
// ---- the person's front door ---------------------------------------------------------------
/**
/**
@@ -147,6 +147,18 @@ function inboxPage(config) {
@@ -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. */
/** Mount the page, its files and Shared's. Before the API's 404 handler. */
function mountPages(app, { config }) {
function mountPages(app, { config }) {
app.use('/shared', ovServe.handler());
app.use('/shared', ovServe.handler());
@@ -167,6 +179,12 @@ function mountPages(app, { config }) {
@@ -167,6 +179,12 @@ function mountPages(app, { config }) {
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
res.type('html').send(inbox);
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;
let html = null;
app.get('/', (req, res) => {
app.get('/', (req, res) => {
html = html || homePage(config);
html = html || homePage(config);
@@ -176,4 +194,4 @@ function mountPages(app, { 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 };
@@ -51,6 +51,11 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
@@ -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.strictEqual(inboxJs.status, 200);
assert.match(inboxJs.text, /Connected mailboxes/, 'Inbox AI shows the connected mailboxes');
assert.match(inboxJs.text, /Connected mailboxes/, 'Inbox AI shows the connected mailboxes');
assert.match(inboxJs.text, /imap-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');
const navbar = await s.get('/shared/navbar.js');
assert.strictEqual(navbar.status, 200);
assert.strictEqual(navbar.status, 200);
const missing = await s.get('/js/nope.js');
const missing = await s.get('/js/nope.js');
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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/);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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);
+});