openvibers/openvibe.mail Commits

Add Stalwart spam scoring to person mail (#31)

OP OpenVibers committed 4 days ago · 78eea7d0e3 · 13 files, +197 −23

* Add Stalwart spam scoring to person mail

* Merge remote-tracking branch 'origin/main' into mail/person-spam-score

# Conflicts:
#	server/domain/person-mail.js

Changes

ddiff --git a/migrations/0015_person_spam_scoring.sql b/migrations/0015_person_spam_scoring.sql
nnew file mode 100644
iindex 0000000..f639270
---- /dev/null
++++ b/migrations/0015_person_spam_scoring.sql
@@@ -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);
ddiff --git a/public/js/mail-providers.js b/public/js/mail-providers.js
iindex c415a40..9ac7214 100644
---- a/public/js/mail-providers.js
++++ b/public/js/mail-providers.js
@@@ -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();
ddiff --git a/server/domain/compose.js b/server/domain/compose.js
iindex 28149bd..8cd54d4 100644
---- a/server/domain/compose.js
++++ b/server/domain/compose.js
@@@ -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 = createNe
try {
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';
ddiff --git a/server/domain/person-mail.js b/server/domain/person-mail.js
iindex a366b24..23bce16 100644
---- a/server/domain/person-mail.js
++++ b/server/domain/person-mail.js
@@@ -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, policy
let 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, policy
else result = await providers.sendWithFailover({ db, config, from: sender, to, message, kind: 'person', smtp: mailer, fetchImpl, now });
} catch { result = { ok: false, uncertain: true, reason: 'The provider response was uncertain.' }; }
const state = result.ok ? 'accepted' : result.uncertain ? 'uncertain' : 'failed';
-- await db.exec(sql`UPDATE mail_person_sends SET status=${state},provider_id=${result.route?.providerId || null},provider_message_id=${result.messageId || null} WHERE id=${id}`);
++ 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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 362108c..e2dce6d 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f914ede..6f517c1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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>`,
});
}
ddiff --git a/server/http/person-routes.js b/server/http/person-routes.js
iindex 4899dc0..af07afe 100644
---- a/server/http/person-routes.js
++++ b/server/http/person-routes.js
@@@ -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);
}));
ddiff --git a/server/stalwart/client.js b/server/stalwart/client.js
iindex 2a01ff8..7cb74d2 100644
---- a/server/stalwart/client.js
++++ b/server/stalwart/client.js
@@@ -473,6 +473,19 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
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 {
jmap,
one,
@@@ -486,6 +499,7 @@ function createStalwartClient({ config, fetchImpl = globalThis.fetch, log = cons
readAttachment,
setSeen,
trashMessage,
++ classifySpam,
deleteMessages,
destroyAccount,
messageBody,
ddiff --git a/test/helpers/stalwart.js b/test/helpers/stalwart.js
iindex 28d48a0..45f14c8 100644
---- a/test/helpers/stalwart.js
++++ b/test/helpers/stalwart.js
@@@ -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 }) {
ddiff --git a/test/page.test.js b/test/page.test.js
iindex a791171..d4a7cc8 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -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');
ddiff --git a/test/person-mail.test.js b/test/person-mail.test.js
iindex 319a5bf..045f5fe 100644
---- a/test/person-mail.test.js
++++ b/test/person-mail.test.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', a
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) => {
const s = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' } });
t.after(() => s.close());
ddiff --git a/test/stalwart-client.test.js b/test/stalwart-client.test.js
nnew file mode 100644
iindex 0000000..dc518f7
---- /dev/null
++++ b/test/stalwart-client.test.js
@@@ -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');
++});
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex 50179b4..d2fb498 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -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.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 concurrent
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') }] } });
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 an
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 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(); }
});