openvibers/openvibe.mail Commits
Add Stalwart spam scoring to person mail (#31)
Changes
nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- Record Stalwart's outbound classification without retaining message content, and let the owner tune score cutoffs.++ALTER TABLE mail_person_sends DROP CONSTRAINT mail_person_sends_status_check;++ALTER TABLE mail_person_sends ADD CONSTRAINT mail_person_sends_status_check++ CHECK(status IN ('sending','accepted','failed','uncertain','bounced','complaint','held','refused','scoring_failed'));++ALTER TABLE mail_person_sends ADD COLUMN spam_score double precision;++ALTER TABLE mail_person_sends ADD COLUMN spam_result text;++ALTER TABLE mail_person_sends ADD COLUMN spam_outcome text;++ALTER TABLE mail_person_sends ADD COLUMN spam_scored_at timestamptz;++CREATE TABLE mail_person_spam_settings (++ id boolean PRIMARY KEY DEFAULT true CHECK (id),++ hold_score double precision NOT NULL DEFAULT 5 CHECK (hold_score >= 0 AND hold_score <= 100),++ refuse_score double precision NOT NULL DEFAULT 10 CHECK (refuse_score > hold_score AND refuse_score <= 200),++ updated_at timestamptz NOT NULL DEFAULT now()++);++INSERT INTO mail_person_spam_settings(id) VALUES(true);@@@ -4,6 +4,7 @@if (!root) return;var list = root.querySelector('[data-provider-list]');var form = root.querySelector('[data-provider-form]');++ var spamForm = root.querySelector('[data-spam-form]');var domainForm = root.querySelector('[data-domain-form]');var domainProviders = root.querySelector('[data-domain-providers]');var domainStatus = root.querySelector('[data-domain-status]');@@@ -17,6 +18,8 @@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.';++ spamForm.elements.hold_score.value = data.spam_thresholds.hold_score;++ spamForm.elements.refuse_score.value = data.spam_thresholds.refuse_score;list.replaceChildren();domainProviders.replaceChildren();(data.providers || []).forEach(function (provider) {@@@ -29,8 +32,14 @@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); });++ }).catch(function (e) { status.textContent = ''; say(e.message); });}++ spamForm.addEventListener('submit', function (event) {++ event.preventDefault(); say('');++ var body = { hold_score: Number(spamForm.elements.hold_score.value), refuse_score: Number(spamForm.elements.refuse_score.value) };++ request('/api/v1/admin/mail/spam-settings', { method: 'PUT', body: JSON.stringify(body) })++ .then(function () { status.textContent = 'Spam thresholds saved.'; load(); }).catch(function (e) { say(e.message); });++ });form.addEventListener('submit', function (event) {event.preventDefault(); say('');var values = new FormData(form); var id = 'smtp_' + crypto.randomUUID();@@@ -93,7 +93,7 @@ async function mime(payload, id, now, reportUrl = null) {disableFileAccess: true, disableUrlAccess: true,attachments: payload.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.content,'base64'), contentType: a.type })) }).compile().build();}--async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNetworkPolicy(config) }) {++async function runOutbox({ db, config, smtp, stalwart, now = Date.now(), policy = createNetworkPolicy(config) }) {// A lost acknowledgement cannot safely be retried. Show it for review instead of duplicating mail.await db.exec(sql`UPDATE mail_compose_outbox SET state='uncertain',updated_at=now() WHERE state='sending' AND updated_at < now() - interval '15 minutes'`);const due = await db.many(sql`SELECT id FROM mail_compose_outbox WHERE state='queued' AND send_at <= ${new Date(now).toISOString()} AND undo_until <= ${new Date(now).toISOString()} ORDER BY send_at LIMIT 20`);@@@ -104,7 +104,7 @@ async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNetry {const p = row.payload;const result = await person.send({ db,config,owner:row.owner_subject,from:p.from,-- recipients:[...new Set([...p.to,...p.cc,...p.bcc])],subject:p.subject,body:p.text,policy,smtp,now:new Date(now),++ recipients:[...new Set([...p.to,...p.cc,...p.bcc])],subject:p.subject,body:p.text,policy,smtp,stalwart,now:new Date(now),makeMessage:async ({ id:deliveryId }) => (await mime(p,id,now,`${config.baseUrl}/report/${deliveryId}`)).toString('utf8') });outcomes = result.results || [{ state:'failed', reason:result.detail || 'Sending unavailable.' }];state = result.status === 200 ? 'sent' : outcomes.some((o) => o.status === 'uncertain') ? 'uncertain' : 'failed';@@@ -27,12 +27,12 @@ function dailyLimit(signals, history) {return 20;}function paused(history) {-- const accepted = history.filter((row) => ['accepted','bounced','complaint'].includes(row.status));-- if (accepted.length < 10) return null;-- const bounced = accepted.filter((row) => row.status === 'bounced').length;-- const complaints = accepted.filter((row) => row.status === 'complaint').length;-- if (complaints / accepted.length >= 0.01) return 'Sending is paused after a complaint. Contact Mail support to review it.';-- if (bounced / accepted.length >= 0.1) return 'Sending is paused after too many bounced messages. Check your recipients and contact Mail support.';++ const counted = history.filter((row) => ['accepted','bounced','complaint','held','refused'].includes(row.status));++ if (counted.length < 10) return null;++ const bouncedOrHeld = counted.filter((row) => ['bounced','held','refused'].includes(row.status)).length;++ const complaints = counted.filter((row) => row.status === 'complaint').length;++ if (complaints / counted.length >= 0.01) return 'Sending is paused after a complaint. Contact Mail support to review it.';++ if (bouncedOrHeld / counted.length >= 0.1) return 'Sending is paused after too many bounced or spam-held messages. Edit held messages and contact Mail support if needed.';return null;}async function recentHistory(db, owner) {@@@ -84,6 +84,26 @@ function buildMessage({ from, to, subject, body, id, reportBase, now }) {return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;}++async function spamThresholds(db) {++ return await db.maybe(sql`SELECT hold_score,refuse_score FROM mail_person_spam_settings WHERE id=true`)++ || { hold_score: 5, refuse_score: 10 };++}++++function validateSpamThresholds(input) {++ const hold = Number(input?.hold_score), refuse = Number(input?.refuse_score);++ if (!Number.isFinite(hold) || !Number.isFinite(refuse) || hold < 0 || hold > 100 || refuse <= hold || refuse > 200) {++ throw new Error('Use a hold score from 0 to 100 and a higher refuse score up to 200.');++ }++ return { hold_score: hold, refuse_score: refuse };++}++++async function saveSpamThresholds({ db, input }) {++ const value = validateSpamThresholds(input);++ await db.exec(sql`INSERT INTO mail_person_spam_settings(id,hold_score,refuse_score) VALUES(true,${value.hold_score},${value.refuse_score})++ ON CONFLICT(id) DO UPDATE SET hold_score=EXCLUDED.hold_score,refuse_score=EXCLUDED.refuse_score,updated_at=now()`);++ return value;++}++async function checkPolicy({ db, owner, policy }) {let signals;try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }@@@ -95,14 +115,14 @@ async function checkPolicy({ db, owner, policy }) {return { limit };}--async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch, makeMessage = null }) {++async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, stalwart, now = new Date(), fetchImpl = globalThis.fetch, makeMessage = null }) {const sender = cleanAddress(from);const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };try { if (!makeMessage) buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };-- const checked = await checkPolicy({ db,owner,policy });++ const checked = await checkPolicy({ db, owner, policy });if (checked.code) return checked;const limit = checked.limit;const day = now.toISOString().slice(0,10);@@@ -123,6 +143,32 @@ async function send({ db, config, owner, from, recipients, subject, body, policylet message;try { message = makeMessage ? await makeMessage({ id, to, now }) : buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }++ let thresholds;++ let classification;++ try {++ thresholds = await spamThresholds(db);++ if (typeof stalwart?.classifySpam !== 'function') throw new Error('Spam classification is unavailable.');++ classification = await stalwart.classifySpam({ message, from: sender, to });++ if (classification?.score === null || classification?.score === undefined || String(classification.score).trim() === ''++ || !Number.isFinite(Number(classification.score)) || !['spam','ham','reject','discard'].includes(classification?.result)) {++ throw new Error('Spam classification returned an invalid result.');++ }++ } catch {++ await db.exec(sql`UPDATE mail_person_sends SET status='scoring_failed',spam_result='error',spam_outcome='failed',spam_scored_at=now() WHERE id=${id}`);++ results.push({ id, to, status: 'scoring_failed', reason: 'The message could not be checked for spam. Edit it and try again later.' });++ continue;++ }++ const score = Number(classification.score);++ if (classification.result === 'reject' || classification.result === 'discard' || score >= Number(thresholds.refuse_score)) {++ await db.exec(sql`UPDATE mail_person_sends SET status='refused',spam_score=${score},spam_result=${classification.result},spam_outcome='refused',spam_scored_at=now() WHERE id=${id}`);++ results.push({ id, to, status: 'refused', spam_score: score, reason: 'Stalwart refused this message as spam. Edit it before sending again.' });++ continue;++ }++ if (score >= Number(thresholds.hold_score)) {++ await db.exec(sql`UPDATE mail_person_sends SET status='held',spam_score=${score},spam_result=${classification.result},spam_outcome='held',spam_scored_at=now() WHERE id=${id}`);++ results.push({ id, to, status: 'held', spam_score: score, reason: 'This message was held for a high spam score. Edit it before sending again.' });++ continue;++ }let result;const route = sending.routeFor(to, config);try {@@@ -131,11 +177,11 @@ async function send({ db, config, owner, from, recipients, subject, body, policyelse result = await providers.sendWithFailover({ db, config, from: sender, to, message, kind: 'person', smtp: mailer, fetchImpl, now });} catch { result = { ok: false, uncertain: true, reason: 'The provider response was uncertain.' }; }const state = result.ok ? 'accepted' : result.uncertain ? 'uncertain' : 'failed';-- await db.exec(sql`UPDATE mail_person_sends SET status=${state},provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);++ await db.exec(sql`UPDATE mail_person_sends SET status=${state},spam_score=${score},spam_result=${classification.result},spam_outcome=${state},spam_scored_at=now(),provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);if (result.ok) await db.exec(sql`UPDATE mail_person_daily SET accepted=accepted+1 WHERE owner_subject=${owner} AND day=${day}`);results.push({ id, to, status: state, reason: result.ok ? null : String(result.reason || 'The provider refused this message.').slice(0,180) });}return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };}--module.exports = { checkPolicy, cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };++module.exports = { checkPolicy, cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, spamThresholds, validateSpamThresholds, saveSpamThresholds, send };@@@ -38,6 +38,7 @@ const answering = require('../domain/agent-answering');const outboundProviders = require('../domain/outbound-providers');const providerWebhooks = require('../domain/provider-webhooks');const providerDomains = require('../domain/provider-domains');++const addresses = require('../domain/person-mail');/** Whether this inbox answers mail (track HO6): off unless the person turned it on. */const agentInboxesAnswering = (db, owner, inbox) => answering.getAnswering({ db, owner, inbox });@@@ -84,7 +85,12 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt}));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) });++ const spam_thresholds = await addresses.spamThresholds(db);++ return res.json({ outbound_enabled: Boolean(config.outbound.enabled), providers: rows.map(outboundProviders.publicProvider), spam_thresholds });++ }));++ router.put('/api/v1/admin/mail/spam-settings', ownerOnly, wrap(async (req, res) => {++ try { return res.json({ spam_thresholds: await addresses.saveSpamThresholds({ db, input: req.body || {} }) }); }++ catch (err) { return fail(req, res, 422, 'mail.spam_thresholds.invalid', err.message); }}));router.put('/api/v1/admin/mail/providers/:id', ownerOnly, wrap(async (req, res) => {const input = { ...(req.body || {}), id: req.params.id };@@@ -156,7 +156,7 @@ function providerAdminPage(config) {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>`,++ 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="spam-heading"><h2 id="spam-heading">Person mail spam scoring</h2><p>Stalwart checks every person message before delivery. Scores at or above hold are returned for editing; scores at or above refuse are rejected.</p><form data-spam-form><label>Hold score <input name="hold_score" type="number" min="0" max="100" step="0.1" required></label><label>Refuse score <input name="refuse_score" type="number" min="0.1" max="200" step="0.1" required></label><button type="submit">Save spam thresholds</button></form></section><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>`,});}@@@ -44,7 +44,7 @@ function mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap,router.post('/api/v1/me/mail/send', write, humanOnly, wrap(async (req, res) => {const result = await addresses.send({ db, config, owner: ownerOf(req), from: req.body?.from,-- recipients: req.body?.to, subject: req.body?.subject, body: req.body?.body, policy, smtp, fetchImpl });++ recipients: req.body?.to, subject: req.body?.subject, body: req.body?.body, policy, smtp, stalwart, fetchImpl });if (result.code) return fail(req, res, result.status, result.code, result.detail);return noStore(res).status(result.status).json(result);}));@@@ -473,6 +473,19 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = consreturn Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));}++ /** Classify an outbound person message without queueing or delivering it. The raw message is sent only to the++ * Stalwart management action and is never persisted or logged by this client. */++ async function classifySpam({ message, from, to }) {++ const result = await one('x:Action/set', { create: { classify: {++ '@type': 'ClassifySpam', message, remoteIp: '127.0.0.1',++ ehloDomain: new URL(base).hostname || 'localhost', mailFrom: from,++ rcptTo: { [to]: true }, isTls: false,++ } } }, 'spam');++ const action = result.created && result.created.classify;++ if (!action) throw new StalwartError('the mail server did not return a spam classification');++ return { score: Number(action.score), result: String(action.result || '').toLowerCase() };++ }++return {jmap,one,@@@ -486,6 +499,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = consreadAttachment,setSeen,trashMessage,++ classifySpam,deleteMessages,destroyAccount,messageBody,@@@ -9,7 +9,7 @@*/function createFakeStalwart() {const mailboxes = new Map(); // account address → messages-- const calls = { invalidateCaches: 0, listMessages: 0, deleteMessages: 0, destroyAccount: 0, messageBody: 0 };++ const calls = { invalidateCaches: 0, listMessages: 0, deleteMessages: 0, destroyAccount: 0, messageBody: 0, classifySpam: 0 };let counter = 0;function mailbox(address) {@@@ -21,6 +21,7 @@ function createFakeStalwart() {return {calls,mailboxes,++ async classifySpam() { calls.classifySpam += 1; return { score: 0, result: 'ham' }; },/** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [], attachments = [], spam = false }) {@@@ -6,9 +6,19 @@*/const test = require('node:test');const assert = require('node:assert');++const fs = require('node:fs');++const path = require('node:path');const { boot } = require('./helpers/boot');const { asset, siteIdentity } = require('../server/http/pages');++test('provider admin controls save Stalwart spam score cutoffs', () => {++ const script = fs.readFileSync(path.join(__dirname, '../public/js/mail-providers.js'), 'utf8');++ assert.match(script, /data-spam-form/);++ assert.match(script, /\/api\/v1\/admin\/mail\/spam-settings/);++ assert.match(script, /hold_score/);++ assert.match(script, /refuse_score/);++});++test('the navbar takes its service and name from the Contracts site block', () => {assert.deepStrictEqual(siteIdentity({ id: 'mail', site: { name: 'Email' } }), { service: 'mail', name: 'OpenVibe.Email' });assert.deepStrictEqual(siteIdentity({ id: 'mail', site: { name: 'OpenVibe.Post' } }), { service: 'mail', name: 'OpenVibe.Post' }, 'a brand name is not doubled');@@@ -53,7 +63,7 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',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']) {++ for (const marker of ['free_daily', 'price_per_thousand', 'monthly_budget', 'rate_limit_per_minute', 'enabled_kinds', 'lookup_count', 'bounce_rate', 'spend_this_month', 'spam-settings', 'hold_score', 'refuse_score']) {assert.ok(providerJs.text.includes(marker), marker);}const navbar = await s.get('/shared/navbar.js');@@@ -7,6 +7,7 @@ const person = require('../server/domain/person-mail');const domains = require('../server/domain/custom-domains');const providers = require('../server/domain/outbound-providers');const providerDomains = require('../server/domain/provider-domains');++const { mountPersonMail } = require('../server/http/person-routes');const { sql } = require('openvibe-sdk/db');test('daily allowance grows with trusted history and pauses on bounce or complaint rates', () => {@@@ -41,6 +42,53 @@ test('person send checks trust, recipient cap and concurrent daily allowance', aassert.match(over.body.detail, /20/);});++test('Stalwart score fixtures hold borderline mail, refuse spam, send clean mail, and fail closed', async (t) => {++ const submitted = [];++ const smtp = { sendMessage: async (_route, message) => { submitted.push(message); return { ok: true, code: 250 }; } };++ const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' }, smtp });++ t.after(() => s.close());++ const alice = s.network.addUser('alice', { ageDays: 3 });++ const made = await s.post('/api/v1/me/mail/person-addresses', { as: alice, json: { name: 'alice', kind: 'name' } });++ const fixtures = [++ { score: 0.2, result: 'ham', status: 'accepted' },++ { score: 7, result: 'spam', status: 'held' },++ { score: 12, result: 'spam', status: 'refused' },++ ];++ let scoreFixture;++ s.mail.classifySpam = async ({ message }) => {++ assert.match(message, /Subject: Hello/);++ return scoreFixture;++ };++ const send = (to) => s.post('/api/v1/me/mail/send', { as: alice, json: { from: made.body.address, to: [to], subject: 'Hello', body: 'Fixture text.' } });++ for (const fixture of fixtures) {++ scoreFixture = fixture;++ const result = await send(`${fixture.status}@openvibe.email`);++ assert.equal(result.body.results[0].status, fixture.status, result.text);++ }++ assert.equal(submitted.length, 1, 'held and refused fixtures never reach the delivery transport');++ assert.equal(await s.db.value("SELECT count(*) FROM mail_person_sends WHERE spam_score IS NOT NULL"), 3);++ scoreFixture = null;++ s.mail.classifySpam = async () => { throw new Error('fixture scoring outage'); };++ const failed = await send('[email protected]');++ assert.equal(failed.body.results[0].status, 'scoring_failed');++ assert.match(failed.body.results[0].reason, /could not be checked/);++ assert.equal(submitted.length, 1, 'a scoring failure never reaches the delivery transport');++ assert.equal(await s.db.value("SELECT count(*) FROM mail_person_sends WHERE spam_result='error' AND spam_outcome='failed'"), 1);++});++++test('owner can tune spam cutoffs and repeated holds pause sending', async (t) => {++ assert.equal(typeof mountPersonMail, 'function');++ const s = await boot();++ t.after(() => s.close());++ const owner = s.network.addUser('owner', { role: 'owner' });++ const user = s.network.addUser('regular');++ assert.equal((await s.put('/api/v1/admin/mail/spam-settings', { as: user, json: { hold_score: 4, refuse_score: 9 } })).status, 404);++ const saved = await s.put('/api/v1/admin/mail/spam-settings', { as: owner, json: { hold_score: 4, refuse_score: 9 } });++ assert.deepEqual(saved.body.spam_thresholds, { hold_score: 4, refuse_score: 9 });++ assert.equal((await s.put('/api/v1/admin/mail/spam-settings', { as: owner, json: { hold_score: 9, refuse_score: 4 } })).status, 422);++ assert.match(person.paused([...Array(9).fill({ status: 'accepted' }), { status: 'held' }]), /held/);++});++test('forwarding refuses unverified targets and accepts only Network verified account emails', async (t) => {const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' } });t.after(() => s.close());nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++++const test = require('node:test');++const assert = require('node:assert/strict');++const { createStalwartClient } = require('../server/stalwart/client');++++test('Stalwart ClassifySpam receives the raw message in a non-delivering management action', async () => {++ let request;++ const client = createStalwartClient({++ config: { stalwart: { url: 'http://127.0.0.1:8080', adminUser: 'admin', adminPassword: 'fixture' } },++ fetchImpl: async (url, options) => {++ request = { url, options, body: JSON.parse(options.body) };++ return new Response(JSON.stringify({ methodResponses: [['x:Action/set', { created: { classify: { score: 6.5, result: 'spam' } } }, 'spam']] }), { status: 200 });++ },++ });++ const message = 'From: [email protected]\r\nTo: [email protected]\r\nSubject: fixture\r\n\r\nMessage content';++ assert.deepEqual(await client.classifySpam({ message, from: '[email protected]', to: '[email protected]' }), { score: 6.5, result: 'spam' });++ assert.equal(request.url, 'http://127.0.0.1:8080/jmap');++ const action = request.body.methodCalls[0][1].create.classify;++ assert.equal(action['@type'], 'ClassifySpam');++ assert.equal(action.message, message);++ assert.deepEqual(action.rcptTo, { '[email protected]': true });++ assert.equal(action.remoteIp, '127.0.0.1');++});@@@ -42,10 +42,10 @@ test('undo cancels persistently, owner isolation and idempotency survive a repeaassert.equal((await s.post(B+'/send',{ as:alice,json:payload })).body.id,id);assert.equal((await s.get(B+'/outbox/'+id,{ as:bob })).status,404);assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:bob })).status,409);-- await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)-1 });++ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:Date.parse(undo_until)-1 });assert.equal(sent.length,0);assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:alice })).status,200);-- await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)+1000 });++ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:Date.parse(undo_until)+1000 });assert.equal(sent.length,0);assert.equal((await s.get(B+'/outbox',{ as:alice })).body.messages[0].state,'cancelled');} finally { await s.close(); }@@@ -56,8 +56,8 @@ test('scheduled send waits, delivers rich MIME and claims once across concurrentconst at = Date.now()+3600000;const response = await s.post(B+'/send',{ as:alice,json:{ ...payload,send_at:new Date(at).toISOString(),cc:['[email protected]'],bcc:['[email protected]'],attachments:[{ name:'hello.txt',type:'text/plain',content:Buffer.from('attachment bytes').toString('base64') }] } });assert.equal(response.status,202,JSON.stringify(response.body));-- await compose.runOutbox({ db:s.db,config:s.config,smtp,now:at-1 }); assert.equal(sent.length,0);-- await Promise.all([compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 }),compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 })]);++ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:at-1 }); assert.equal(sent.length,0);++ await Promise.all([compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:at+1 }),compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:at+1 })]);assert.equal(sent.length,3);const parsed = await simpleParser(sent[0].message);assert.equal(parsed.subject,'A subject'); assert.match(parsed.html,/<b>letter<\/b>/);@@@ -79,7 +79,7 @@ test('sender, account trust and outbound controls are checked before queueing anassert.equal((await s.post(B+'/send',{ as:young,json:{ ...payload,from:address } })).status,422);const r = await s.post(B+'/send',{ as:alice,json:payload }); assert.equal(r.status,202);const policy = { trust:async () => { throw new Error('offline'); } };-- await compose.runOutbox({ db:s.db,config:s.config,smtp,policy,now:Date.parse(r.body.undo_until)+1 });++ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,policy,now:Date.parse(r.body.undo_until)+1 });assert.equal(sent.length,0); assert.equal((await s.get(B+'/outbox/'+r.body.id,{ as:alice })).body.state,'failed');} finally { await s.close(); }});
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- Record Stalwart's outbound classification without retaining message content, and let the owner tune score cutoffs.
+ALTER TABLE mail_person_sends DROP CONSTRAINT mail_person_sends_status_check;
+ALTER TABLE mail_person_sends ADD CONSTRAINT mail_person_sends_status_check
+ CHECK(status IN ('sending','accepted','failed','uncertain','bounced','complaint','held','refused','scoring_failed'));
+ALTER TABLE mail_person_sends ADD COLUMN spam_score double precision;
+ALTER TABLE mail_person_sends ADD COLUMN spam_result text;
+ALTER TABLE mail_person_sends ADD COLUMN spam_outcome text;
+ALTER TABLE mail_person_sends ADD COLUMN spam_scored_at timestamptz;
+CREATE TABLE mail_person_spam_settings (
+ id boolean PRIMARY KEY DEFAULT true CHECK (id),
+ hold_score double precision NOT NULL DEFAULT 5 CHECK (hold_score >= 0 AND hold_score <= 100),
+ refuse_score double precision NOT NULL DEFAULT 10 CHECK (refuse_score > hold_score AND refuse_score <= 200),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+INSERT INTO mail_person_spam_settings(id) VALUES(true);
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
if (!root) return;
if (!root) return;
var list = root.querySelector('[data-provider-list]');
var list = root.querySelector('[data-provider-list]');
var form = root.querySelector('[data-provider-form]');
var form = root.querySelector('[data-provider-form]');
+ var spamForm = root.querySelector('[data-spam-form]');
var domainForm = root.querySelector('[data-domain-form]');
var domainForm = root.querySelector('[data-domain-form]');
var domainProviders = root.querySelector('[data-domain-providers]');
var domainProviders = root.querySelector('[data-domain-providers]');
var domainStatus = root.querySelector('[data-domain-status]');
var domainStatus = root.querySelector('[data-domain-status]');
@@ -17,6 +18,8 @@
@@ -17,6 +18,8 @@
function load() {
function load() {
request('/api/v1/admin/mail/providers').then(function (data) {
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.';
status.textContent = data.outbound_enabled ? 'Outbound delivery is enabled by MAIL_OUTBOUND.' : 'Outbound delivery is closed by MAIL_OUTBOUND.';
+ spamForm.elements.hold_score.value = data.spam_thresholds.hold_score;
+ spamForm.elements.refuse_score.value = data.spam_thresholds.refuse_score;
list.replaceChildren();
list.replaceChildren();
domainProviders.replaceChildren();
domainProviders.replaceChildren();
(data.providers || []).forEach(function (provider) {
(data.providers || []).forEach(function (provider) {
@@ -29,8 +32,14 @@
@@ -29,8 +32,14 @@
card.append(title, detail, remove); list.append(card);
card.append(title, detail, remove); list.append(card);
});
});
if (!data.providers || !data.providers.length) list.textContent = 'No outbound providers are configured.';
if (!data.providers || !data.providers.length) list.textContent = 'No outbound providers are configured.';
- }).catch(function (e) { status.textContent = ''; say(e.message); });
+ }).catch(function (e) { status.textContent = ''; say(e.message); });
}
}
+ spamForm.addEventListener('submit', function (event) {
+ event.preventDefault(); say('');
+ var body = { hold_score: Number(spamForm.elements.hold_score.value), refuse_score: Number(spamForm.elements.refuse_score.value) };
+ request('/api/v1/admin/mail/spam-settings', { method: 'PUT', body: JSON.stringify(body) })
+ .then(function () { status.textContent = 'Spam thresholds saved.'; load(); }).catch(function (e) { say(e.message); });
+ });
form.addEventListener('submit', function (event) {
form.addEventListener('submit', function (event) {
event.preventDefault(); say('');
event.preventDefault(); say('');
var values = new FormData(form); var id = 'smtp_' + crypto.randomUUID();
var values = new FormData(form); var id = 'smtp_' + crypto.randomUUID();
@@ -93,7 +93,7 @@ async function mime(payload, id, now, reportUrl = null) {
@@ -93,7 +93,7 @@ async function mime(payload, id, now, reportUrl = null) {
disableFileAccess: true, disableUrlAccess: true,
disableFileAccess: true, disableUrlAccess: true,
attachments: payload.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.content,'base64'), contentType: a.type })) }).compile().build();
attachments: payload.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.content,'base64'), contentType: a.type })) }).compile().build();
}
}
-async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNetworkPolicy(config) }) {
+async function runOutbox({ db, config, smtp, stalwart, now = Date.now(), policy = createNetworkPolicy(config) }) {
// A lost acknowledgement cannot safely be retried. Show it for review instead of duplicating mail.
// A lost acknowledgement cannot safely be retried. Show it for review instead of duplicating mail.
await db.exec(sql`UPDATE mail_compose_outbox SET state='uncertain',updated_at=now() WHERE state='sending' AND updated_at < now() - interval '15 minutes'`);
await db.exec(sql`UPDATE mail_compose_outbox SET state='uncertain',updated_at=now() WHERE state='sending' AND updated_at < now() - interval '15 minutes'`);
const due = await db.many(sql`SELECT id FROM mail_compose_outbox WHERE state='queued' AND send_at <= ${new Date(now).toISOString()} AND undo_until <= ${new Date(now).toISOString()} ORDER BY send_at LIMIT 20`);
const due = await db.many(sql`SELECT id FROM mail_compose_outbox WHERE state='queued' AND send_at <= ${new Date(now).toISOString()} AND undo_until <= ${new Date(now).toISOString()} ORDER BY send_at LIMIT 20`);
@@ -104,7 +104,7 @@ async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNe
@@ -104,7 +104,7 @@ async function runOutbox({ db, config, smtp, now = Date.now(), policy = createNe
try {
try {
const p = row.payload;
const p = row.payload;
const result = await person.send({ db,config,owner:row.owner_subject,from:p.from,
const result = await person.send({ db,config,owner:row.owner_subject,from:p.from,
- recipients:[...new Set([...p.to,...p.cc,...p.bcc])],subject:p.subject,body:p.text,policy,smtp,now:new Date(now),
+ recipients:[...new Set([...p.to,...p.cc,...p.bcc])],subject:p.subject,body:p.text,policy,smtp,stalwart,now:new Date(now),
makeMessage:async ({ id:deliveryId }) => (await mime(p,id,now,`${config.baseUrl}/report/${deliveryId}`)).toString('utf8') });
makeMessage:async ({ id:deliveryId }) => (await mime(p,id,now,`${config.baseUrl}/report/${deliveryId}`)).toString('utf8') });
outcomes = result.results || [{ state:'failed', reason:result.detail || 'Sending unavailable.' }];
outcomes = result.results || [{ state:'failed', reason:result.detail || 'Sending unavailable.' }];
state = result.status === 200 ? 'sent' : outcomes.some((o) => o.status === 'uncertain') ? 'uncertain' : 'failed';
state = result.status === 200 ? 'sent' : outcomes.some((o) => o.status === 'uncertain') ? 'uncertain' : 'failed';
@@ -27,12 +27,12 @@ function dailyLimit(signals, history) {
@@ -27,12 +27,12 @@ function dailyLimit(signals, history) {
return 20;
return 20;
}
}
function paused(history) {
function paused(history) {
- const accepted = history.filter((row) => ['accepted','bounced','complaint'].includes(row.status));
- if (accepted.length < 10) return null;
- const bounced = accepted.filter((row) => row.status === 'bounced').length;
- const complaints = accepted.filter((row) => row.status === 'complaint').length;
- if (complaints / accepted.length >= 0.01) return 'Sending is paused after a complaint. Contact Mail support to review it.';
- if (bounced / accepted.length >= 0.1) return 'Sending is paused after too many bounced messages. Check your recipients and contact Mail support.';
+ const counted = history.filter((row) => ['accepted','bounced','complaint','held','refused'].includes(row.status));
+ if (counted.length < 10) return null;
+ const bouncedOrHeld = counted.filter((row) => ['bounced','held','refused'].includes(row.status)).length;
+ const complaints = counted.filter((row) => row.status === 'complaint').length;
+ if (complaints / counted.length >= 0.01) return 'Sending is paused after a complaint. Contact Mail support to review it.';
+ if (bouncedOrHeld / counted.length >= 0.1) return 'Sending is paused after too many bounced or spam-held messages. Edit held messages and contact Mail support if needed.';
return null;
return null;
}
}
async function recentHistory(db, owner) {
async function recentHistory(db, owner) {
@@ -84,6 +84,26 @@ function buildMessage({ from, to, subject, body, id, reportBase, now }) {
@@ -84,6 +84,26 @@ function buildMessage({ from, to, subject, body, id, reportBase, now }) {
return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;
return `${headers.join('\r\n')}\r\n\r\n${text.replace(/\r\n/g,'\n').replace(/\n/g,'\r\n')}\r\n\r\nReport unwanted mail: ${reportBase}/report/${id}`;
}
}
+async function spamThresholds(db) {
+ return await db.maybe(sql`SELECT hold_score,refuse_score FROM mail_person_spam_settings WHERE id=true`)
+ || { hold_score: 5, refuse_score: 10 };
+}
+
+function validateSpamThresholds(input) {
+ const hold = Number(input?.hold_score), refuse = Number(input?.refuse_score);
+ if (!Number.isFinite(hold) || !Number.isFinite(refuse) || hold < 0 || hold > 100 || refuse <= hold || refuse > 200) {
+ throw new Error('Use a hold score from 0 to 100 and a higher refuse score up to 200.');
+ }
+ return { hold_score: hold, refuse_score: refuse };
+}
+
+async function saveSpamThresholds({ db, input }) {
+ const value = validateSpamThresholds(input);
+ await db.exec(sql`INSERT INTO mail_person_spam_settings(id,hold_score,refuse_score) VALUES(true,${value.hold_score},${value.refuse_score})
+ ON CONFLICT(id) DO UPDATE SET hold_score=EXCLUDED.hold_score,refuse_score=EXCLUDED.refuse_score,updated_at=now()`);
+ return value;
+}
+
async function checkPolicy({ db, owner, policy }) {
async function checkPolicy({ db, owner, policy }) {
let signals;
let signals;
try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }
try { signals = await policy.trust(owner); } catch { return { status: 503, code: 'mail.send.trust_unavailable', detail: 'Network trust checks are unavailable; try again later.' }; }
@@ -95,14 +115,14 @@ async function checkPolicy({ db, owner, policy }) {
@@ -95,14 +115,14 @@ async function checkPolicy({ db, owner, policy }) {
return { limit };
return { limit };
}
}
-async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, now = new Date(), fetchImpl = globalThis.fetch, makeMessage = null }) {
+async function send({ db, config, owner, from, recipients, subject, body, policy, smtp, stalwart, now = new Date(), fetchImpl = globalThis.fetch, makeMessage = null }) {
const sender = cleanAddress(from);
const sender = cleanAddress(from);
const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];
const targets = Array.isArray(recipients) ? [...new Set(recipients.map(cleanAddress))] : [];
if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };
if (!sender || !targets.length || targets.length > 10 || targets.includes(null)) return { status: 422, code: 'mail.send.recipients', detail: 'Use 1–10 valid recipients.' };
try { if (!makeMessage) buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }
try { if (!makeMessage) buildMessage({ from: sender, to: targets[0], subject, body, id: crypto.randomUUID(), reportBase: config.baseUrl, now }); }
catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
catch (err) { return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };
if (!await senderAllowed(db, owner, sender)) return { status: 403, code: 'mail.send.sender', detail: 'Choose an address you own with verified sending DNS.' };
- const checked = await checkPolicy({ db,owner,policy });
+ const checked = await checkPolicy({ db, owner, policy });
if (checked.code) return checked;
if (checked.code) return checked;
const limit = checked.limit;
const limit = checked.limit;
const day = now.toISOString().slice(0,10);
const day = now.toISOString().slice(0,10);
@@ -123,6 +143,32 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
@@ -123,6 +143,32 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
let message;
let message;
try { message = makeMessage ? await makeMessage({ id, to, now }) : buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }
try { message = makeMessage ? await makeMessage({ id, to, now }) : buildMessage({ from: sender, to, subject, body, id, reportBase: config.baseUrl, now }); }
catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
catch (err) { await db.exec(sql`UPDATE mail_person_sends SET status='failed' WHERE id=${id}`); return { status: 422, code: 'mail.send.invalid', detail: err.message }; }
+ let thresholds;
+ let classification;
+ try {
+ thresholds = await spamThresholds(db);
+ if (typeof stalwart?.classifySpam !== 'function') throw new Error('Spam classification is unavailable.');
+ classification = await stalwart.classifySpam({ message, from: sender, to });
+ if (classification?.score === null || classification?.score === undefined || String(classification.score).trim() === ''
+ || !Number.isFinite(Number(classification.score)) || !['spam','ham','reject','discard'].includes(classification?.result)) {
+ throw new Error('Spam classification returned an invalid result.');
+ }
+ } catch {
+ await db.exec(sql`UPDATE mail_person_sends SET status='scoring_failed',spam_result='error',spam_outcome='failed',spam_scored_at=now() WHERE id=${id}`);
+ results.push({ id, to, status: 'scoring_failed', reason: 'The message could not be checked for spam. Edit it and try again later.' });
+ continue;
+ }
+ const score = Number(classification.score);
+ if (classification.result === 'reject' || classification.result === 'discard' || score >= Number(thresholds.refuse_score)) {
+ await db.exec(sql`UPDATE mail_person_sends SET status='refused',spam_score=${score},spam_result=${classification.result},spam_outcome='refused',spam_scored_at=now() WHERE id=${id}`);
+ results.push({ id, to, status: 'refused', spam_score: score, reason: 'Stalwart refused this message as spam. Edit it before sending again.' });
+ continue;
+ }
+ if (score >= Number(thresholds.hold_score)) {
+ await db.exec(sql`UPDATE mail_person_sends SET status='held',spam_score=${score},spam_result=${classification.result},spam_outcome='held',spam_scored_at=now() WHERE id=${id}`);
+ results.push({ id, to, status: 'held', spam_score: score, reason: 'This message was held for a high spam score. Edit it before sending again.' });
+ continue;
+ }
let result;
let result;
const route = sending.routeFor(to, config);
const route = sending.routeFor(to, config);
try {
try {
@@ -131,11 +177,11 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
@@ -131,11 +177,11 @@ async function send({ db, config, owner, from, recipients, subject, body, policy
else result = await providers.sendWithFailover({ db, config, from: sender, to, message, kind: 'person', smtp: mailer, fetchImpl, now });
else result = await providers.sendWithFailover({ db, config, from: sender, to, message, kind: 'person', smtp: mailer, fetchImpl, now });
} catch { result = { ok: false, uncertain: true, reason: 'The provider response was uncertain.' }; }
} catch { result = { ok: false, uncertain: true, reason: 'The provider response was uncertain.' }; }
const state = result.ok ? 'accepted' : result.uncertain ? 'uncertain' : 'failed';
const state = result.ok ? 'accepted' : result.uncertain ? 'uncertain' : 'failed';
- await db.exec(sql`UPDATE mail_person_sends SET status=${state},provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);
+ await db.exec(sql`UPDATE mail_person_sends SET status=${state},spam_score=${score},spam_result=${classification.result},spam_outcome=${state},spam_scored_at=now(),provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);
if (result.ok) await db.exec(sql`UPDATE mail_person_daily SET accepted=accepted+1 WHERE owner_subject=${owner} AND day=${day}`);
if (result.ok) await db.exec(sql`UPDATE mail_person_daily SET accepted=accepted+1 WHERE owner_subject=${owner} AND day=${day}`);
results.push({ id, to, status: state, reason: result.ok ? null : String(result.reason || 'The provider refused this message.').slice(0,180) });
results.push({ id, to, status: state, reason: result.ok ? null : String(result.reason || 'The provider refused this message.').slice(0,180) });
}
}
return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };
return { status: results.every((r) => r.status === 'accepted') ? 200 : 207, results, remaining: Math.max(0,limit-(await db.maybe(sql`SELECT attempts FROM mail_person_daily WHERE owner_subject=${owner} AND day=${day}`)).attempts) };
}
}
-module.exports = { checkPolicy, cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, send };
+module.exports = { checkPolicy, cleanAddress, validateName, dailyLimit, paused, createAddress, senderAllowed, buildMessage, spamThresholds, validateSpamThresholds, saveSpamThresholds, send };
@@ -38,6 +38,7 @@ const answering = require('../domain/agent-answering');
@@ -38,6 +38,7 @@ const answering = require('../domain/agent-answering');
const outboundProviders = require('../domain/outbound-providers');
const outboundProviders = require('../domain/outbound-providers');
const providerWebhooks = require('../domain/provider-webhooks');
const providerWebhooks = require('../domain/provider-webhooks');
const providerDomains = require('../domain/provider-domains');
const providerDomains = require('../domain/provider-domains');
+const addresses = require('../domain/person-mail');
/** 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 });
@@ -84,7 +85,12 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
@@ -84,7 +85,12 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
}));
}));
router.get('/api/v1/admin/mail/providers', ownerOnly, wrap(async (_req, res) => {
router.get('/api/v1/admin/mail/providers', ownerOnly, wrap(async (_req, res) => {
const rows = await outboundProviders.list(db);
const rows = await outboundProviders.list(db);
- return res.json({ outbound_enabled: Boolean(config.outbound.enabled), providers: rows.map(outboundProviders.publicProvider) });
+ const spam_thresholds = await addresses.spamThresholds(db);
+ return res.json({ outbound_enabled: Boolean(config.outbound.enabled), providers: rows.map(outboundProviders.publicProvider), spam_thresholds });
+ }));
+ router.put('/api/v1/admin/mail/spam-settings', ownerOnly, wrap(async (req, res) => {
+ try { return res.json({ spam_thresholds: await addresses.saveSpamThresholds({ db, input: req.body || {} }) }); }
+ catch (err) { return fail(req, res, 422, 'mail.spam_thresholds.invalid', err.message); }
}));
}));
router.put('/api/v1/admin/mail/providers/:id', ownerOnly, wrap(async (req, res) => {
router.put('/api/v1/admin/mail/providers/:id', ownerOnly, wrap(async (req, res) => {
const input = { ...(req.body || {}), id: req.params.id };
const input = { ...(req.body || {}), id: req.params.id };
@@ -156,7 +156,7 @@ function providerAdminPage(config) {
@@ -156,7 +156,7 @@ function providerAdminPage(config) {
description: 'Owner controls for outbound mail providers and monthly budgets.', canonical: `${base}/admin/mail/providers`,
description: 'Owner controls for outbound mail providers and monthly budgets.', canonical: `${base}/admin/mail/providers`,
robots: 'noindex, nofollow', home: '/', navLinks: nav.links, navbar: nav,
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>`,
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>`,
+ 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="spam-heading"><h2 id="spam-heading">Person mail spam scoring</h2><p>Stalwart checks every person message before delivery. Scores at or above hold are returned for editing; scores at or above refuse are rejected.</p><form data-spam-form><label>Hold score <input name="hold_score" type="number" min="0" max="100" step="0.1" required></label><label>Refuse score <input name="refuse_score" type="number" min="0.1" max="200" step="0.1" required></label><button type="submit">Save spam thresholds</button></form></section><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>`,
});
});
}
}
@@ -44,7 +44,7 @@ function mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap,
@@ -44,7 +44,7 @@ function mountPersonMail(router, { db, config, stalwart, smtp, principal, wrap,
router.post('/api/v1/me/mail/send', write, humanOnly, wrap(async (req, res) => {
router.post('/api/v1/me/mail/send', write, humanOnly, wrap(async (req, res) => {
const result = await addresses.send({ db, config, owner: ownerOf(req), from: req.body?.from,
const result = await addresses.send({ db, config, owner: ownerOf(req), from: req.body?.from,
- recipients: req.body?.to, subject: req.body?.subject, body: req.body?.body, policy, smtp, fetchImpl });
+ recipients: req.body?.to, subject: req.body?.subject, body: req.body?.body, policy, smtp, stalwart, fetchImpl });
if (result.code) return fail(req, res, result.status, result.code, result.detail);
if (result.code) return fail(req, res, result.status, result.code, result.detail);
return noStore(res).status(result.status).json(result);
return noStore(res).status(result.status).json(result);
}));
}));
@@ -473,6 +473,19 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -473,6 +473,19 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
return Boolean(result.updated && Object.prototype.hasOwnProperty.call(result.updated, id));
}
}
+ /** Classify an outbound person message without queueing or delivering it. The raw message is sent only to the
+ * Stalwart management action and is never persisted or logged by this client. */
+ async function classifySpam({ message, from, to }) {
+ const result = await one('x:Action/set', { create: { classify: {
+ '@type': 'ClassifySpam', message, remoteIp: '127.0.0.1',
+ ehloDomain: new URL(base).hostname || 'localhost', mailFrom: from,
+ rcptTo: { [to]: true }, isTls: false,
+ } } }, 'spam');
+ const action = result.created && result.created.classify;
+ if (!action) throw new StalwartError('the mail server did not return a spam classification');
+ return { score: Number(action.score), result: String(action.result || '').toLowerCase() };
+ }
+
return {
return {
jmap,
jmap,
one,
one,
@@ -486,6 +499,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
@@ -486,6 +499,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
readAttachment,
readAttachment,
setSeen,
setSeen,
trashMessage,
trashMessage,
+ classifySpam,
deleteMessages,
deleteMessages,
destroyAccount,
destroyAccount,
messageBody,
messageBody,
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
*/
*/
function createFakeStalwart() {
function createFakeStalwart() {
const mailboxes = new Map(); // account address → messages
const mailboxes = new Map(); // account address → messages
- const calls = { invalidateCaches: 0, listMessages: 0, deleteMessages: 0, destroyAccount: 0, messageBody: 0 };
+ const calls = { invalidateCaches: 0, listMessages: 0, deleteMessages: 0, destroyAccount: 0, messageBody: 0, classifySpam: 0 };
let counter = 0;
let counter = 0;
function mailbox(address) {
function mailbox(address) {
@@ -21,6 +21,7 @@ function createFakeStalwart() {
@@ -21,6 +21,7 @@ function createFakeStalwart() {
return {
return {
calls,
calls,
mailboxes,
mailboxes,
+ async classifySpam() { calls.classifySpam += 1; return { score: 0, result: 'ham' }; },
/** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */
/** Put a message in a mailbox, as if it had arrived; the first header is the server's own stamp. */
deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [], attachments = [], spam = false }) {
deliver({ mailboxAddress, to, from = '[email protected]', subject = 'Hello', text = '', html = '', receivedAt = null, headers = [], attachments = [], spam = false }) {
@@ -6,9 +6,19 @@
@@ -6,9 +6,19 @@
*/
*/
const test = require('node:test');
const test = require('node:test');
const assert = require('node:assert');
const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
const { boot } = require('./helpers/boot');
const { boot } = require('./helpers/boot');
const { asset, siteIdentity } = require('../server/http/pages');
const { asset, siteIdentity } = require('../server/http/pages');
+test('provider admin controls save Stalwart spam score cutoffs', () => {
+ const script = fs.readFileSync(path.join(__dirname, '../public/js/mail-providers.js'), 'utf8');
+ assert.match(script, /data-spam-form/);
+ assert.match(script, /\/api\/v1\/admin\/mail\/spam-settings/);
+ assert.match(script, /hold_score/);
+ assert.match(script, /refuse_score/);
+});
+
test('the navbar takes its service and name from the Contracts site block', () => {
test('the navbar takes its service and name from the Contracts site block', () => {
assert.deepStrictEqual(siteIdentity({ id: 'mail', site: { name: 'Email' } }), { service: 'mail', name: 'OpenVibe.Email' });
assert.deepStrictEqual(siteIdentity({ id: 'mail', site: { name: 'Email' } }), { service: 'mail', name: 'OpenVibe.Email' });
assert.deepStrictEqual(siteIdentity({ id: 'mail', site: { name: 'OpenVibe.Post' } }), { service: 'mail', name: 'OpenVibe.Post' }, 'a brand name is not doubled');
assert.deepStrictEqual(siteIdentity({ id: 'mail', site: { name: 'OpenVibe.Post' } }), { service: 'mail', name: 'OpenVibe.Post' }, 'a brand name is not doubled');
@@ -53,7 +63,7 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
@@ -53,7 +63,7 @@ test('the page\'s files are served by content hash, and Shared\'s from /shared',
assert.match(inboxJs.text, /imap-mailboxes/);
assert.match(inboxJs.text, /imap-mailboxes/);
const providerJs = await s.get(asset('js/mail-providers.js'));
const providerJs = await s.get(asset('js/mail-providers.js'));
assert.strictEqual(providerJs.status, 200);
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']) {
+ for (const marker of ['free_daily', 'price_per_thousand', 'monthly_budget', 'rate_limit_per_minute', 'enabled_kinds', 'lookup_count', 'bounce_rate', 'spend_this_month', 'spam-settings', 'hold_score', 'refuse_score']) {
assert.ok(providerJs.text.includes(marker), marker);
assert.ok(providerJs.text.includes(marker), marker);
}
}
const navbar = await s.get('/shared/navbar.js');
const navbar = await s.get('/shared/navbar.js');
@@ -7,6 +7,7 @@ const person = require('../server/domain/person-mail');
@@ -7,6 +7,7 @@ const person = require('../server/domain/person-mail');
const domains = require('../server/domain/custom-domains');
const domains = require('../server/domain/custom-domains');
const providers = require('../server/domain/outbound-providers');
const providers = require('../server/domain/outbound-providers');
const providerDomains = require('../server/domain/provider-domains');
const providerDomains = require('../server/domain/provider-domains');
+const { mountPersonMail } = require('../server/http/person-routes');
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
test('daily allowance grows with trusted history and pauses on bounce or complaint rates', () => {
test('daily allowance grows with trusted history and pauses on bounce or complaint rates', () => {
@@ -41,6 +42,53 @@ test('person send checks trust, recipient cap and concurrent daily allowance', a
@@ -41,6 +42,53 @@ test('person send checks trust, recipient cap and concurrent daily allowance', a
assert.match(over.body.detail, /20/);
assert.match(over.body.detail, /20/);
});
});
+test('Stalwart score fixtures hold borderline mail, refuse spam, send clean mail, and fail closed', async (t) => {
+ const submitted = [];
+ const smtp = { sendMessage: async (_route, message) => { submitted.push(message); return { ok: true, code: 250 }; } };
+ const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' }, smtp });
+ t.after(() => s.close());
+ const alice = s.network.addUser('alice', { ageDays: 3 });
+ const made = await s.post('/api/v1/me/mail/person-addresses', { as: alice, json: { name: 'alice', kind: 'name' } });
+ const fixtures = [
+ { score: 0.2, result: 'ham', status: 'accepted' },
+ { score: 7, result: 'spam', status: 'held' },
+ { score: 12, result: 'spam', status: 'refused' },
+ ];
+ let scoreFixture;
+ s.mail.classifySpam = async ({ message }) => {
+ assert.match(message, /Subject: Hello/);
+ return scoreFixture;
+ };
+ const send = (to) => s.post('/api/v1/me/mail/send', { as: alice, json: { from: made.body.address, to: [to], subject: 'Hello', body: 'Fixture text.' } });
+ for (const fixture of fixtures) {
+ scoreFixture = fixture;
+ const result = await send(`${fixture.status}@openvibe.email`);
+ assert.equal(result.body.results[0].status, fixture.status, result.text);
+ }
+ assert.equal(submitted.length, 1, 'held and refused fixtures never reach the delivery transport');
+ assert.equal(await s.db.value("SELECT count(*) FROM mail_person_sends WHERE spam_score IS NOT NULL"), 3);
+ scoreFixture = null;
+ s.mail.classifySpam = async () => { throw new Error('fixture scoring outage'); };
+ const failed = await send('[email protected]');
+ assert.equal(failed.body.results[0].status, 'scoring_failed');
+ assert.match(failed.body.results[0].reason, /could not be checked/);
+ assert.equal(submitted.length, 1, 'a scoring failure never reaches the delivery transport');
+ assert.equal(await s.db.value("SELECT count(*) FROM mail_person_sends WHERE spam_result='error' AND spam_outcome='failed'"), 1);
+});
+
+test('owner can tune spam cutoffs and repeated holds pause sending', async (t) => {
+ assert.equal(typeof mountPersonMail, 'function');
+ const s = await boot();
+ t.after(() => s.close());
+ const owner = s.network.addUser('owner', { role: 'owner' });
+ const user = s.network.addUser('regular');
+ assert.equal((await s.put('/api/v1/admin/mail/spam-settings', { as: user, json: { hold_score: 4, refuse_score: 9 } })).status, 404);
+ const saved = await s.put('/api/v1/admin/mail/spam-settings', { as: owner, json: { hold_score: 4, refuse_score: 9 } });
+ assert.deepEqual(saved.body.spam_thresholds, { hold_score: 4, refuse_score: 9 });
+ assert.equal((await s.put('/api/v1/admin/mail/spam-settings', { as: owner, json: { hold_score: 9, refuse_score: 4 } })).status, 422);
+ assert.match(person.paused([...Array(9).fill({ status: 'accepted' }), { status: 'held' }]), /held/);
+});
+
test('forwarding refuses unverified targets and accepts only Network verified account emails', async (t) => {
test('forwarding refuses unverified targets and accepts only Network verified account emails', async (t) => {
const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' } });
const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' } });
t.after(() => s.close());
t.after(() => s.close());
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { createStalwartClient } = require('../server/stalwart/client');
+
+test('Stalwart ClassifySpam receives the raw message in a non-delivering management action', async () => {
+ let request;
+ const client = createStalwartClient({
+ config: { stalwart: { url: 'http://127.0.0.1:8080', adminUser: 'admin', adminPassword: 'fixture' } },
+ fetchImpl: async (url, options) => {
+ request = { url, options, body: JSON.parse(options.body) };
+ return new Response(JSON.stringify({ methodResponses: [['x:Action/set', { created: { classify: { score: 6.5, result: 'spam' } } }, 'spam']] }), { status: 200 });
+ },
+ });
+ const message = 'From: [email protected]\r\nTo: [email protected]\r\nSubject: fixture\r\n\r\nMessage content';
+ assert.deepEqual(await client.classifySpam({ message, from: '[email protected]', to: '[email protected]' }), { score: 6.5, result: 'spam' });
+ assert.equal(request.url, 'http://127.0.0.1:8080/jmap');
+ const action = request.body.methodCalls[0][1].create.classify;
+ assert.equal(action['@type'], 'ClassifySpam');
+ assert.equal(action.message, message);
+ assert.deepEqual(action.rcptTo, { '[email protected]': true });
+ assert.equal(action.remoteIp, '127.0.0.1');
+});
@@ -42,10 +42,10 @@ test('undo cancels persistently, owner isolation and idempotency survive a repea
@@ -42,10 +42,10 @@ test('undo cancels persistently, owner isolation and idempotency survive a repea
assert.equal((await s.post(B+'/send',{ as:alice,json:payload })).body.id,id);
assert.equal((await s.post(B+'/send',{ as:alice,json:payload })).body.id,id);
assert.equal((await s.get(B+'/outbox/'+id,{ as:bob })).status,404);
assert.equal((await s.get(B+'/outbox/'+id,{ as:bob })).status,404);
assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:bob })).status,409);
assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:bob })).status,409);
- await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)-1 });
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:Date.parse(undo_until)-1 });
assert.equal(sent.length,0);
assert.equal(sent.length,0);
assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:alice })).status,200);
assert.equal((await s.post(B+'/outbox/'+id+'/cancel',{ as:alice })).status,200);
- await compose.runOutbox({ db:s.db,config:s.config,smtp,now:Date.parse(undo_until)+1000 });
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:Date.parse(undo_until)+1000 });
assert.equal(sent.length,0);
assert.equal(sent.length,0);
assert.equal((await s.get(B+'/outbox',{ as:alice })).body.messages[0].state,'cancelled');
assert.equal((await s.get(B+'/outbox',{ as:alice })).body.messages[0].state,'cancelled');
} finally { await s.close(); }
} finally { await s.close(); }
@@ -56,8 +56,8 @@ test('scheduled send waits, delivers rich MIME and claims once across concurrent
@@ -56,8 +56,8 @@ test('scheduled send waits, delivers rich MIME and claims once across concurrent
const at = Date.now()+3600000;
const at = Date.now()+3600000;
const response = await s.post(B+'/send',{ as:alice,json:{ ...payload,send_at:new Date(at).toISOString(),cc:['[email protected]'],bcc:['[email protected]'],attachments:[{ name:'hello.txt',type:'text/plain',content:Buffer.from('attachment bytes').toString('base64') }] } });
const response = await s.post(B+'/send',{ as:alice,json:{ ...payload,send_at:new Date(at).toISOString(),cc:['[email protected]'],bcc:['[email protected]'],attachments:[{ name:'hello.txt',type:'text/plain',content:Buffer.from('attachment bytes').toString('base64') }] } });
assert.equal(response.status,202,JSON.stringify(response.body));
assert.equal(response.status,202,JSON.stringify(response.body));
- await compose.runOutbox({ db:s.db,config:s.config,smtp,now:at-1 }); assert.equal(sent.length,0);
- await Promise.all([compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 }),compose.runOutbox({ db:s.db,config:s.config,smtp,now:at+1 })]);
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:at-1 }); assert.equal(sent.length,0);
+ await Promise.all([compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:at+1 }),compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,now:at+1 })]);
assert.equal(sent.length,3);
assert.equal(sent.length,3);
const parsed = await simpleParser(sent[0].message);
const parsed = await simpleParser(sent[0].message);
assert.equal(parsed.subject,'A subject'); assert.match(parsed.html,/<b>letter<\/b>/);
assert.equal(parsed.subject,'A subject'); assert.match(parsed.html,/<b>letter<\/b>/);
@@ -79,7 +79,7 @@ test('sender, account trust and outbound controls are checked before queueing an
@@ -79,7 +79,7 @@ test('sender, account trust and outbound controls are checked before queueing an
assert.equal((await s.post(B+'/send',{ as:young,json:{ ...payload,from:address } })).status,422);
assert.equal((await s.post(B+'/send',{ as:young,json:{ ...payload,from:address } })).status,422);
const r = await s.post(B+'/send',{ as:alice,json:payload }); assert.equal(r.status,202);
const r = await s.post(B+'/send',{ as:alice,json:payload }); assert.equal(r.status,202);
const policy = { trust:async () => { throw new Error('offline'); } };
const policy = { trust:async () => { throw new Error('offline'); } };
- await compose.runOutbox({ db:s.db,config:s.config,smtp,policy,now:Date.parse(r.body.undo_until)+1 });
+ await compose.runOutbox({ db:s.db,config:s.config,smtp,stalwart:s.mail,policy,now:Date.parse(r.body.undo_until)+1 });
assert.equal(sent.length,0); assert.equal((await s.get(B+'/outbox/'+r.body.id,{ as:alice })).body.state,'failed');
assert.equal(sent.length,0); assert.equal((await s.get(B+'/outbox/'+r.body.id,{ as:alice })).body.state,'failed');
} finally { await s.close(); }
} finally { await s.close(); }
});
});